commit 6ec8663954dfdc432cb27336a87415ad1f1f32fc Author: renato97 Date: Sat Mar 28 22:53:10 2026 -0300 Initial commit: AbletonMCP-AI complete system - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7010820 --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.log +.task_queue.tmp* + +# MCP/Qwen +.qwen/ +.mcp.json + +# Claude +.claude/ + +# Samples and large media +*.wav +*.mp3 +*.flac +*.aiff +*.aif + +# Large library directories +librerias/ + +# Other remote scripts (not our project) +_Repo/ +_Tools/ +AbletonOSC/ +Abletunes_Free_Templates_Pack/ +AutoTrack_Me_Gusta_Auto/ +AutoTrack_Papi_Clone/ +CompleteTrackBuilder/ +DJAIController/ +DJAIControllerV7/ +MaxForLive/ +GPU_SETUP.md +HUMAN_FEEL_IMPLEMENTATION.md +MCP_SETUP_SUMMARY.md +MCP_VERIFICATION.md +QWEN_MCP_SETUP.md +abletonmcp_init.py +abletonmcp_server.py +add_samples_script.py +agent10_diagnosis.py +agent7_lead_task.py +agent8_vocals.py +agent8_vocals_load.py +agent9_fx_loader.py +codex.md +generate_song.py +generate_track.py +sample/ +nul + +# Generated audio cache +*.sample_embeddings.json + +# AbletonMCP_AI generated audio +AppData/ \ No newline at end of file diff --git a/AbletonMCP_AI/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md b/AbletonMCP_AI/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md new file mode 100644 index 0000000..ff7dcf3 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md @@ -0,0 +1,39 @@ +# Abletunes Template Notes + +Estos templates muestran patrones claros de produccion real que conviene copiar en el generador. + +## Patrones fuertes + +- Son `arrangement-first`, no `session-first`. En los cuatro sets los clips viven casi enteros en Arrangement y las scenes estan vacias o sin rol productivo. +- Todos usan locators para secciones (`Intro`, `Breakdown`, `Drop`, `Break`, `Outro`, `End`) y esas secciones casi siempre caen en bloques de `16`, `32`, `64`, `96` o `128` beats. +- Siempre hay jerarquia por grupos: drums/top drums, bass, instruments, vox, fx. +- Casi siempre existe un `SC Trigger` o pista equivalente dedicada al sidechain. +- Los drums no son una sola pista. Hay capas separadas para kick, clap, snare, hats, ride, perc, fills, crashes, risers y FX. +- Las partes armonicas tampoco son una sola pista. Aparecen capas distintas para bassline, reese/sub, chord, piano, string, pluck, lead y layers. +- Mezclan MIDI e audio de forma agresiva. Un productor no se queda solo con MIDI: imprime loops, resamples, freeze y audios procesados cuando hace falta. +- Hay bastante tratamiento por pista: `Eq8`, `Compressor2`, `Reverb`, `AutoFilter`, `PingPongDelay`, `GlueCompressor`, `MultibandDynamics`, `Limiter`, `Saturator`. + +## Lo que mas importa para el MCP + +- El generador no tiene que crear "un loop largo". Tiene que crear secciones con mutaciones claras entre una y otra. +- Cada seccion necesita variacion de densidad, no solo mute/unmute basico. Los templates meten fills, crashes, reverse FX, chants, top loops y capas extra solo en puntos de tension. +- El arreglo profesional usa mas pistas especializadas de las que hoy genera el MCP. La separacion por rol es parte del sonido. +- Hay que imprimir mas audio original derivado del propio proyecto: resamples, reverses, freezes y FX hechos a partir de material propio. +- Los returns son pocos pero concretos. No hace falta llenar de sends; hace falta `reverb`, `delay` y buses de grupo bien usados. + +## Señales concretas vistas en el pack + +- `Abletunes - Dope As F_ck`: `128 BPM`, 6 grupos, 2 returns, `Sylenth1` dominante, mucha automatizacion (`8121` eventos). +- `Abletunes - Freedom`: `126 BPM`, mezcla house mas simple, bateria muy separada, menos automatizacion, mucho `OriginalSimpler` + `Serum`. +- `Abletunes - Hideout`: set largo y cargado, `Massive` + `Sylenth1`, una bateria enorme y mucha automatizacion (`6470` eventos). +- `Abletunes - Nobody's Watching`: enfoque mas stock, usa `Operator`, `Simpler`, bastante audio vocal y FX impresos. + +## Reglas que deberiamos incorporar + +- Generar por defecto en Arrangement, con locators reales y secciones de 16/32 bars. +- Añadir `SC Trigger`, grupos y returns fijos desde el blueprint. +- Separar drums en mas roles: kick, clap main, clap layer, snare fill, hats, ride, perc main, perc FX, crash, reverse, riser. +- Separar armonia y hooks: sub, bassline, chord stab, piano/keys, string/pad, pluck, lead, accent synth. +- Crear eventos de transicion por seccion: uplifter, downlifter, reverse crash, vocal chop, tom fill. +- Imprimir audio derivado del material generado cuando una capa necesite mas impacto o textura. +- Meter automatizacion por seccion en filtros, sends, volumen de grupos y FX de transicion. diff --git a/AbletonMCP_AI/MCP_Server/SAMPLE_SYSTEM_README.md b/AbletonMCP_AI/MCP_Server/SAMPLE_SYSTEM_README.md new file mode 100644 index 0000000..9d6835c --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/SAMPLE_SYSTEM_README.md @@ -0,0 +1,203 @@ +# Sistema de Gestión de Samples - AbletonMCP-AI + +Sistema completo de indexación, clasificación y selección inteligente de samples musicales. + +## Componentes + +### 1. `audio_analyzer.py` - Análisis de Audio + +Detecta automáticamente características de archivos de audio: +- **BPM**: Detección de tempo mediante análisis de onset +- **Key**: Detección de tonalidad mediante cromagrama +- **Tipo**: Clasificación en kick, snare, bass, synth, etc. +- **Características espectrales**: Centroide, rolloff, RMS + +**Uso básico:** +```python +from audio_analyzer import analyze_sample + +result = analyze_sample("path/to/sample.wav") +print(f"BPM: {result['bpm']}, Key: {result['key']}") +print(f"Tipo: {result['sample_type']}") +``` + +**Backends:** +- `librosa`: Análisis completo (requiere instalación) +- `basic`: Análisis por nombre de archivo (sin dependencias) + +### 2. `sample_manager.py` - Gestión de Librería + +Gestor completo de la librería de samples: +- Indexación recursiva de directorios +- Clasificación automática por categorías +- Metadatos extensibles (tags, rating, géneros) +- Búsqueda avanzada con múltiples filtros +- Persistencia en JSON + +**Categorías principales:** +- `drums`: kick, snare, clap, hat, perc, shaker, tom, cymbal +- `bass`: sub, bassline, acid +- `synths`: lead, pad, pluck, chord, fx +- `vocals`: vocal, speech, chant +- `loops`: drum_loop, bass_loop, synth_loop, full_loop +- `one_shots`: hit, noise + +**Uso básico:** +```python +from sample_manager import SampleManager + +# Inicializar +manager = SampleManager(r"C:\Users\ren\embeddings\all_tracks") + +# Escanear +stats = manager.scan_directory(analyze_audio=True) + +# Buscar +kicks = manager.search(sample_type="kick", key="Am", bpm=128) +house_samples = manager.search(genres=["house"], limit=10) + +# Obtener pack completo +pack = manager.get_pack_for_genre("techno", key="F#m", bpm=130) +``` + +### 3. `sample_selector.py` - Selección Inteligente + +Selección contextual basada en género, key y BPM: +- Perfiles de género predefinidos +- Matching armónico entre samples +- Generación de kits de batería coherentes +- Mapeo MIDI automático + +**Géneros soportados:** +- Techno (industrial, minimal, acid) +- House (deep, classic, progressive) +- Tech-House +- Trance (progressive, psy) +- Drum & Bass (liquid, neuro) +- Ambient + +**Uso básico:** +```python +from sample_selector import SampleSelector + +selector = SampleSelector() + +# Seleccionar para un género +group = selector.select_for_genre("techno", key="F#m", bpm=130) + +# Acceder a elementos +group.drums.kick # Sample de kick +group.bass # Lista de bass samples +group.synths # Lista de synths + +# Mapeo MIDI +mapping = selector.get_midi_mapping_for_kit(group.drums) + +# Cambio de key armónico +new_key = selector.suggest_key_change("Am", "fifth_up") # Em +``` + +## Integración con MCP Server + +El servidor MCP expone las siguientes herramientas: + +### Gestión de Librería +- `scan_sample_library` - Escanear directorio de samples +- `get_sample_library_stats` - Estadísticas de la librería + +### Búsqueda y Selección +- `advanced_search_samples` - Búsqueda con filtros múltiples +- `select_samples_for_genre` - Selección automática por género +- `get_drum_kit_mapping` - Kit de batería con mapeo MIDI +- `get_sample_pack_for_project` - Pack completo para proyecto + +### Análisis y Compatibilidad +- `analyze_audio_file` - Analizar archivo de audio +- `find_compatible_samples` - Encontrar samples compatibles +- `suggest_key_change` - Sugerir cambios de tonalidad + +## Estructura de Datos + +### Sample +```python +@dataclass +class Sample: + id: str # ID único + name: str # Nombre del archivo + path: str # Ruta completa + category: str # Categoría principal + subcategory: str # Subcategoría + sample_type: str # Tipo específico + key: Optional[str] # Tonalidad (Am, F#m, C) + bpm: Optional[float] # BPM + duration: float # Duración en segundos + genres: List[str] # Géneros asociados + tags: List[str] # Tags + rating: int # Rating 0-5 +``` + +### DrumKit +```python +@dataclass +class DrumKit: + name: str + kick: Optional[Sample] + snare: Optional[Sample] + clap: Optional[Sample] + hat_closed: Optional[Sample] + hat_open: Optional[Sample] + perc1: Optional[Sample] + perc2: Optional[Sample] +``` + +## Mapeo MIDI + +Notas estándar para drums: +- `36` (C1): Kick +- `38` (D1): Snare +- `39` (D#1): Clap +- `42` (F#1): Closed Hat +- `46` (A#1): Open Hat +- `41` (F1): Tom Low +- `49` (C#2): Crash + +## Ejemplos de Uso + +### Crear un track completo +```python +# Seleccionar samples para techno +selector = get_selector() +group = selector.select_for_genre("techno", key="F#m", bpm=130) + +# Usar con Ableton +ableton = get_ableton_connection() + +# Crear tracks y cargar samples +for i, sample in enumerate([group.drums.kick, group.drums.snare]): + if sample: + print(f"Cargar {sample.name} en track {i}") +``` + +### Buscar samples compatibles +```python +# Encontrar samples que combinen con un kick +kick = manager.get_by_path("path/to/kick.wav") +compatible = selector.find_compatible_samples(kick, max_results=5) + +for sample, score in compatible: + print(f"{sample.name}: {score:.1%} compatible") +``` + +## Archivos Generados + +- `.sample_cache/sample_library.json` - Índice de la librería +- `.sample_cache/library_stats.json` - Estadísticas + +## Dependencias Opcionales + +Para análisis de audio completo: +```bash +pip install librosa soundfile numpy +``` + +Sin estas dependencias, el sistema funciona en modo "basic" usando metadatos de los nombres de archivo. diff --git a/AbletonMCP_AI/MCP_Server/__init__.py b/AbletonMCP_AI/MCP_Server/__init__.py new file mode 100644 index 0000000..aef464d --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/__init__.py @@ -0,0 +1,26 @@ +""" +MCP Server para AbletonMCP-AI +Servidor FastMCP que conecta Claude con Ableton Live 12 +""" + +from .server import mcp, main +from .song_generator import SongGenerator +from .sample_index import SampleIndex + +# Nuevo sistema de samples +try: + SAMPLE_SYSTEM_AVAILABLE = True +except ImportError: + SAMPLE_SYSTEM_AVAILABLE = False + +__all__ = [ + 'mcp', 'main', + 'SongGenerator', 'SampleIndex', +] + +if SAMPLE_SYSTEM_AVAILABLE: + __all__.extend([ + 'SampleManager', 'Sample', 'get_manager', + 'SampleSelector', 'get_selector', 'DrumKit', 'InstrumentGroup', + 'AudioAnalyzer', 'analyze_sample', 'SampleType', + ]) diff --git a/AbletonMCP_AI/MCP_Server/audio_analyzer.py b/AbletonMCP_AI/MCP_Server/audio_analyzer.py new file mode 100644 index 0000000..29feefa --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/audio_analyzer.py @@ -0,0 +1,681 @@ +""" +audio_analyzer.py - Análisis de audio para detección de Key y BPM + +Proporciona análisis básico de archivos de audio para extraer: +- BPM (tempo) mediante detección de onset y autocorrelación +- Key (tonalidad) mediante análisis de cromagrama +- Características espectrales para clasificación +""" + +import os +import logging +import numpy as np +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, List +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger("AudioAnalyzer") + +# Constantes musicales +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] +KEY_PROFILES = { + # Perfiles de Krumhansl-Schmuckler para detección de tonalidad + 'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], + 'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17] +} + +CIRCLE_OF_FIFTHS_MAJOR = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F'] +CIRCLE_OF_FIFTHS_MINOR = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm'] + + +class SampleType(Enum): + """Tipos de samples musicales""" + KICK = "kick" + SNARE = "snare" + CLAP = "clap" + HAT_CLOSED = "hat_closed" + HAT_OPEN = "hat_open" + HAT = "hat" + PERC = "perc" + SHAKER = "shaker" + TOM = "tom" + CRASH = "crash" + RIDE = "ride" + BASS = "bass" + SYNTH = "synth" + PAD = "pad" + LEAD = "lead" + PLUCK = "pluck" + ARP = "arp" + CHORD = "chord" + STAB = "stab" + VOCAL = "vocal" + FX = "fx" + LOOP = "loop" + AMBIENCE = "ambience" + UNKNOWN = "unknown" + + +@dataclass +class AudioFeatures: + """Características extraídas de un archivo de audio""" + bpm: Optional[float] + key: Optional[str] + key_confidence: float + duration: float + sample_rate: int + sample_type: SampleType + spectral_centroid: float + spectral_rolloff: float + zero_crossing_rate: float + rms_energy: float + is_harmonic: bool + is_percussive: bool + suggested_genres: List[str] + + +class AudioAnalyzer: + """ + Analizador de audio para samples musicales. + + Soporta múltiples backends: + - librosa (recomendado, más preciso) + - basic (fallback sin dependencias externas, basado en nombre de archivo) + """ + + def __init__(self, backend: str = "auto"): + """ + Inicializa el analizador de audio. + + Args: + backend: 'librosa', 'basic', o 'auto' (detecta automáticamente) + """ + self.backend = backend + self._librosa_available = False + self._soundfile_available = False + + if backend in ("auto", "librosa"): + self._check_librosa() + + if self._librosa_available: + logger.info("Usando backend: librosa") + else: + logger.info("Usando backend: basic (análisis por nombre de archivo)") + + def _check_librosa(self): + """Verifica si librosa está disponible""" + try: + import librosa + import soundfile as sf + self._librosa_available = True + self._soundfile_available = True + self.librosa = librosa + self.sf = sf + except ImportError: + self._librosa_available = False + self._soundfile_available = False + + def analyze(self, file_path: str) -> AudioFeatures: + """ + Analiza un archivo de audio y extrae características. + + Args: + file_path: Ruta al archivo de audio + + Returns: + AudioFeatures con los datos extraídos + """ + path = Path(file_path) + + if not path.exists(): + raise FileNotFoundError(f"Archivo no encontrado: {file_path}") + + # Intentar análisis con librosa si está disponible + if self._librosa_available: + try: + return self._analyze_with_librosa(file_path) + except Exception as e: + logger.warning(f"Error con librosa: {e}, usando análisis básico") + + # Fallback a análisis básico + return self._analyze_basic(file_path) + + def _analyze_with_librosa(self, file_path: str) -> AudioFeatures: + """Análisis completo usando librosa""" + # Cargar audio + y, sr = self.librosa.load(file_path, sr=None, mono=True) + + # Duración + duration = self.librosa.get_duration(y=y, sr=sr) + + # Detectar BPM + tempo, _ = self.librosa.beat.beat_track(y=y, sr=sr) + bpm = float(tempo) if isinstance(tempo, (int, float, np.number)) else None + + # Análisis espectral + spectral_centroids = self.librosa.feature.spectral_centroid(y=y, sr=sr)[0] + spectral_rolloffs = self.librosa.feature.spectral_rolloff(y=y, sr=sr)[0] + zcr = self.librosa.feature.zero_crossing_rate(y)[0] + rms = self.librosa.feature.rms(y=y)[0] + + # Detectar key + key, key_confidence = self._detect_key_librosa(y, sr) + + # Clasificación percusivo vs armónico + is_percussive = self._is_percussive(y, sr) + is_harmonic = not is_percussive and duration > 1.0 + + # Determinar tipo de sample + sample_type = self._classify_sample_type( + file_path, is_percussive, is_harmonic, duration, + float(np.mean(spectral_centroids)), float(np.mean(rms)) + ) + + # Sugerir géneros + suggested_genres = self._suggest_genres(sample_type, bpm, key) + + return AudioFeatures( + bpm=bpm, + key=key, + key_confidence=key_confidence, + duration=duration, + sample_rate=sr, + sample_type=sample_type, + spectral_centroid=float(np.mean(spectral_centroids)), + spectral_rolloff=float(np.mean(spectral_rolloffs)), + zero_crossing_rate=float(np.mean(zcr)), + rms_energy=float(np.mean(rms)), + is_harmonic=is_harmonic, + is_percussive=is_percussive, + suggested_genres=suggested_genres + ) + + def _detect_key_librosa(self, y: np.ndarray, sr: int) -> Tuple[Optional[str], float]: + """ + Detecta la tonalidad usando cromagrama y correlación con perfiles. + """ + try: + # Calcular cromagrama + chroma = self.librosa.feature.chroma_stft(y=y, sr=sr) + chroma_avg = np.mean(chroma, axis=1) + + # Normalizar + chroma_avg = chroma_avg / (np.sum(chroma_avg) + 1e-10) + + best_key = None + best_score = -np.inf + best_mode = None + + # Probar todas las tonalidades mayores y menores + for mode, profile in KEY_PROFILES.items(): + for i in range(12): + # Rotar el perfil + rotated_profile = np.roll(profile, i) + # Correlación + score = np.corrcoef(chroma_avg, rotated_profile)[0, 1] + + if score > best_score: + best_score = score + best_mode = mode + best_key = NOTE_NAMES[i] + + # Formatear resultado + if best_key: + if best_mode == 'minor': + best_key = best_key + 'm' + confidence = max(0.0, min(1.0, (best_score + 1) / 2)) + return best_key, confidence + + except Exception as e: + logger.warning(f"Error detectando key: {e}") + + return None, 0.0 + + def _is_percussive(self, y: np.ndarray, sr: int) -> bool: + """ + Determina si un sonido es principalmente percusivo. + """ + try: + # Separar componentes armónicos y percusivos + y_harmonic, y_percussive = self.librosa.effects.hpss(y) + + # Calcular energía relativa + energy_harmonic = np.sum(y_harmonic ** 2) + energy_percussive = np.sum(y_percussive ** 2) + total_energy = energy_harmonic + energy_percussive + + if total_energy > 0: + percussive_ratio = energy_percussive / total_energy + return percussive_ratio > 0.6 + + except Exception as e: + logger.warning(f"Error en separación HPSS: {e}") + + # Fallback: usar duración como heurística + duration = len(y) / sr + return duration < 0.5 + + def _analyze_basic(self, file_path: str) -> AudioFeatures: + """ + Análisis básico sin dependencias externas. + Usa metadatos del archivo y nombre para inferir características. + """ + path = Path(file_path) + name = path.stem + + # Extraer del nombre + bpm = self._extract_bpm_from_name(name) + key = self._extract_key_from_name(name) + + # Estimar duración del archivo + duration = self._estimate_duration(file_path) + + # Clasificar por nombre + sample_type = self._classify_by_name(name) + + # Determinar características por tipo + is_percussive = sample_type in [ + SampleType.KICK, SampleType.SNARE, SampleType.CLAP, + SampleType.HAT, SampleType.HAT_CLOSED, SampleType.HAT_OPEN, + SampleType.PERC, SampleType.SHAKER, SampleType.TOM, + SampleType.CRASH, SampleType.RIDE + ] + is_harmonic = sample_type in [ + SampleType.BASS, SampleType.SYNTH, SampleType.PAD, + SampleType.LEAD, SampleType.PLUCK, SampleType.CHORD, + SampleType.VOCAL + ] + + # Valores por defecto basados en tipo + spectral_centroid = 5000.0 if is_percussive else 1000.0 + rms_energy = 0.5 + + suggested_genres = self._suggest_genres(sample_type, bpm, key) + + return AudioFeatures( + bpm=bpm, + key=key, + key_confidence=0.7 if key else 0.0, + duration=duration, + sample_rate=44100, + sample_type=sample_type, + spectral_centroid=spectral_centroid, + spectral_rolloff=spectral_centroid * 2, + zero_crossing_rate=0.1 if is_harmonic else 0.3, + rms_energy=rms_energy, + is_harmonic=is_harmonic, + is_percussive=is_percussive, + suggested_genres=suggested_genres + ) + + def _estimate_duration(self, file_path: str) -> float: + """Estima la duración del archivo de audio""" + try: + import wave + + ext = Path(file_path).suffix.lower() + + if ext == '.wav': + with wave.open(file_path, 'rb') as wav: + frames = wav.getnframes() + rate = wav.getframerate() + return frames / float(rate) + + elif ext in ('.mp3', '.ogg', '.flac', '.aif', '.aiff', '.m4a'): + windows_duration = self._estimate_duration_with_windows_shell(file_path) + if windows_duration > 0: + return windows_duration + # Estimación por tamaño de archivo + size = os.path.getsize(file_path) + # Aproximación: ~176KB por segundo para CD quality stereo + return size / (176.4 * 1024) + + except Exception as e: + logger.warning(f"Error estimando duración: {e}") + + return 0.0 + + def _estimate_duration_with_windows_shell(self, file_path: str) -> float: + """Obtiene la duración usando metadatos del shell de Windows cuando están disponibles.""" + if os.name != 'nt': + return 0.0 + + safe_path = file_path.replace("'", "''") + powershell_command = ( + f"$path = '{safe_path}'; " + "$shell = New-Object -ComObject Shell.Application; " + "$folder = $shell.Namespace((Split-Path $path)); " + "$file = $folder.ParseName((Split-Path $path -Leaf)); " + "$duration = $folder.GetDetailsOf($file, 27); " + "Write-Output $duration" + ) + try: + result = subprocess.run( + f'powershell -NoProfile -Command "{powershell_command}"', + capture_output=True, + text=True, + timeout=5, + check=False, + shell=True, + ) + value = (result.stdout or "").strip() + if not value: + return 0.0 + parts = value.split(':') + if len(parts) == 3: + return (int(parts[0]) * 3600) + (int(parts[1]) * 60) + float(parts[2]) + return 0.0 + except Exception: + return 0.0 + + def _extract_bpm_from_name(self, name: str) -> Optional[float]: + """Extrae BPM del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + r'[_\s\-](\d{2,3})\s*(?:BPM|bpm)?\s*(?:\.wav|\.mp3|\.aif)', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: + return float(bpm) + + return None + + def _extract_key_from_name(self, name: str) -> Optional[str]: + """Extrae key del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', + r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', + r'Key\s+([A-G][#b]?(?:m|min|minor)?)', + r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + # Detectar si es menor + is_minor = 'm' in key.lower() or 'min' in key.lower() + key = key.replace('min', '').replace('minor', '').replace('major', '') + key = key.rstrip('mM') + + if is_minor: + key = key + 'm' + + return key + + return None + + def _classify_sample_type(self, file_path: str, is_percussive: bool, + is_harmonic: bool, duration: float, + spectral_centroid: float, rms: float) -> SampleType: + """Clasifica el tipo de sample basado en características""" + # Primero intentar por nombre + sample_type = self._classify_by_name(Path(file_path).stem) + if sample_type != SampleType.UNKNOWN: + return sample_type + + # Clasificación por características de audio + if is_percussive: + if duration < 0.1: + if spectral_centroid < 2000: + return SampleType.KICK + elif spectral_centroid > 8000: + return SampleType.HAT_CLOSED + else: + return SampleType.SNARE + elif duration < 0.3: + return SampleType.CLAP + else: + return SampleType.PERC + + elif is_harmonic: + if spectral_centroid < 500: + return SampleType.BASS + elif duration > 4.0: + return SampleType.PAD + else: + return SampleType.SYNTH + + return SampleType.UNKNOWN + + def _classify_by_name(self, name: str) -> SampleType: + """Clasifica el tipo de sample basado en su nombre""" + name_lower = name.lower() + + # Mapeo de palabras clave a tipos + keywords = { + SampleType.KICK: ['kick', 'bd', 'bass drum', 'kickdrum', 'kik'], + SampleType.SNARE: ['snare', 'snr', 'sd', 'rim'], + SampleType.CLAP: ['clap', 'clp', 'handclap'], + SampleType.HAT_CLOSED: ['closed hat', 'closedhat', 'chh', 'closed'], + SampleType.HAT_OPEN: ['open hat', 'openhat', 'ohh', 'open'], + SampleType.HAT: ['hat', 'hihat', 'hi-hat', 'hh'], + SampleType.PERC: ['perc', 'percussion', 'conga', 'bongo', 'timb'], + SampleType.SHAKER: ['shaker', 'shake', 'tamb'], + SampleType.TOM: ['tom', 'tomtom'], + SampleType.CRASH: ['crash', 'cymbal'], + SampleType.RIDE: ['ride'], + SampleType.BASS: ['bass', 'bassline', 'sub', '808', 'reese'], + SampleType.SYNTH: ['synth', 'lead', 'arp', 'sequence'], + SampleType.PAD: ['pad', 'atmosphere', 'dron'], + SampleType.PLUCK: ['pluck'], + SampleType.CHORD: ['chord', 'stab'], + SampleType.VOCAL: ['vocal', 'vox', 'voice', 'speech', 'talk'], + SampleType.FX: ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit', 'noise'], + SampleType.LOOP: ['loop', 'full', 'groove'], + } + + for sample_type, words in keywords.items(): + for word in words: + if word in name_lower: + return sample_type + + return SampleType.UNKNOWN + + def _suggest_genres(self, sample_type: SampleType, bpm: Optional[float], + key: Optional[str]) -> List[str]: + """Sugiere géneros musicales apropiados para el sample""" + genres = [] + + if bpm: + if 118 <= bpm <= 128: + genres.extend(['house', 'tech-house', 'deep-house']) + elif 124 <= bpm <= 132: + genres.extend(['tech-house', 'techno']) + elif 132 <= bpm <= 142: + genres.extend(['techno', 'peak-time-techno']) + elif 142 <= bpm <= 150: + genres.extend(['trance', 'hard-techno']) + elif 160 <= bpm <= 180: + genres.extend(['drum-and-bass', 'neurofunk']) + elif bpm < 118: + genres.extend(['downtempo', 'ambient', 'lo-fi']) + + # Por tipo de sample + if sample_type in [SampleType.KICK, SampleType.SNARE, SampleType.CLAP]: + if not genres: + genres = ['techno', 'house'] + elif sample_type == SampleType.BASS: + if not genres: + genres = ['techno', 'house', 'bass-music'] + elif sample_type in [SampleType.SYNTH, SampleType.PAD]: + if not genres: + genres = ['trance', 'progressive', 'ambient'] + + return genres if genres else ['electronic'] + + def get_compatible_key(self, key: str, shift: int = 0) -> str: + """ + Obtiene una key compatible usando el círculo de quintas. + + Args: + key: Key original (ej: 'Am', 'F#m') + shift: Desplazamiento en el círculo (+1 = quinta arriba, -1 = quinta abajo) + + Returns: + Key resultante + """ + is_minor = key.endswith('m') + root = key.rstrip('m') + + if root not in NOTE_NAMES: + return key + + circle = CIRCLE_OF_FIFTHS_MINOR if is_minor else CIRCLE_OF_FIFTHS_MAJOR + + try: + idx = circle.index(key) + new_idx = (idx + shift) % 12 + return circle[new_idx] + except ValueError: + return key + + def calculate_key_compatibility(self, key1: str, key2: str) -> float: + """ + Calcula la compatibilidad entre dos keys (0-1). + + Usa el círculo de quintas: keys cercanas son más compatibles. + """ + if key1 == key2: + return 1.0 + + # Normalizar + def normalize(k): + is_minor = k.endswith('m') + root = k.rstrip('m') + # Convertir bemoles a sostenidos + root = root.replace('Db', 'C#').replace('Eb', 'D#') + root = root.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + return root + ('m' if is_minor else '') + + k1 = normalize(key1) + k2 = normalize(key2) + + if k1 == k2: + return 1.0 + + # Verificar si son modos diferentes de la misma nota + if k1.rstrip('m') == k2.rstrip('m'): + return 0.8 # Mismo root, diferente modo + + # Usar círculo de quintas + is_minor1 = k1.endswith('m') + is_minor2 = k2.endswith('m') + + if is_minor1 != is_minor2: + return 0.3 # Diferente modo, baja compatibilidad + + circle = CIRCLE_OF_FIFTHS_MINOR if is_minor1 else CIRCLE_OF_FIFTHS_MAJOR + + try: + idx1 = circle.index(k1) + idx2 = circle.index(k2) + distance = min(abs(idx1 - idx2), 12 - abs(idx1 - idx2)) + + # Compatibilidad decrece con la distancia + compatibility = max(0.0, 1.0 - (distance * 0.2)) + return compatibility + + except ValueError: + return 0.0 + + +# Instancia global +_analyzer: Optional[AudioAnalyzer] = None + + +def get_analyzer() -> AudioAnalyzer: + """Obtiene la instancia global del analizador""" + global _analyzer + if _analyzer is None: + _analyzer = AudioAnalyzer() + return _analyzer + + +def analyze_sample(file_path: str) -> Dict[str, Any]: + """ + Función de conveniencia para analizar un sample. + + Returns: + Diccionario con las características del sample + """ + analyzer = get_analyzer() + features = analyzer.analyze(file_path) + + return { + 'bpm': features.bpm, + 'key': features.key, + 'key_confidence': features.key_confidence, + 'duration': features.duration, + 'sample_rate': features.sample_rate, + 'sample_type': features.sample_type.value, + 'spectral_centroid': features.spectral_centroid, + 'rms_energy': features.rms_energy, + 'is_harmonic': features.is_harmonic, + 'is_percussive': features.is_percussive, + 'suggested_genres': features.suggested_genres, + } + + +def quick_analyze(file_path: str) -> Dict[str, Any]: + """ + Análisis rápido basado solo en el nombre del archivo. + No requiere dependencias externas. + """ + analyzer = AudioAnalyzer(backend="basic") + features = analyzer.analyze(file_path) + + return { + 'bpm': features.bpm, + 'key': features.key, + 'sample_type': features.sample_type.value, + 'suggested_genres': features.suggested_genres, + } + + +# Testing +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO) + + if len(sys.argv) < 2: + print("Uso: python audio_analyzer.py ") + sys.exit(1) + + file_path = sys.argv[1] + + print(f"\nAnalizando: {file_path}") + print("=" * 50) + + try: + result = analyze_sample(file_path) + + print("\nResultados:") + print(f" BPM: {result['bpm'] or 'No detectado'}") + print(f" Key: {result['key'] or 'No detectado'} (confianza: {result['key_confidence']:.2f})") + print(f" Duración: {result['duration']:.2f}s") + print(f" Tipo: {result['sample_type']}") + print(f" Géneros sugeridos: {', '.join(result['suggested_genres'])}") + print(f" Es percusivo: {result['is_percussive']}") + print(f" Es armónico: {result['is_harmonic']}") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/AbletonMCP_AI/MCP_Server/audio_organizer.py b/AbletonMCP_AI/MCP_Server/audio_organizer.py new file mode 100644 index 0000000..969a11f --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/audio_organizer.py @@ -0,0 +1,117 @@ +import os +import shutil +import glob +import logging +from pathlib import Path +import json + +import wave + +logger = logging.getLogger("AudioOrganizer") +logging.basicConfig(level=logging.INFO) + +CATEGORIES = { + 'kick': ['kick', 'bd', 'bass drum'], + 'snare': ['snare', 'sd', 'clap'], + 'hat': ['hat', 'hh', 'hihat', 'closed hat', 'open hat'], + 'perc': ['perc', 'percussion', 'conga', 'shaker', 'tamb', 'tom'], + 'bass': ['bass', 'sub', '808'], + 'synth': ['synth', 'lead', 'pad', 'arp', 'pluck', 'chord'], + 'vocal': ['vocal', 'vox', 'voice', 'speech', 'chant'], + 'fx': ['fx', 'sweep', 'riser', 'downlifter', 'impact', 'crash', 'fill', 'texture', 'drone', 'noise'] +} + +def get_duration(file_path: str) -> float: + try: + with wave.open(file_path, 'r') as w: + frames = w.getnframes() + rate = w.getframerate() + return frames / float(rate) + except Exception: + pass + + try: + size_bytes = os.path.getsize(file_path) + if file_path.lower().endswith('.mp3'): + return size_bytes / 30000.0 + else: + return size_bytes / 176400.0 + except Exception: + return 0.0 + +def detect_category(name: str) -> str: + name_lower = name.lower() + for cat, keywords in CATEGORIES.items(): + if any(kw in name_lower.split('_') or kw in name_lower.split('-') or kw in name_lower.split(' ') for kw in keywords): + return cat + # Fallback substring check + for cat, keywords in CATEGORIES.items(): + if any(kw in name_lower for kw in keywords): + return cat + if 'loop' in name_lower: + return 'loop_other' + return 'other' + +def get_duration_folder(duration: float) -> str: + if duration <= 2.8: + return "oneshots" + elif duration <= 16.0: + return "loops" + else: + return "textures" + +def organize_library(source_dir: str, dest_dir: str): + logger.info(f"Scanning {source_dir}...") + source_path = Path(source_dir) + dest_path = Path(dest_dir) + + extensions = {'.wav', '.aif', '.aiff', '.mp3'} + + files_to_process = [] + for ext in extensions: + files_to_process.extend(source_path.rglob('*' + ext)) + files_to_process.extend(source_path.rglob('*' + ext.upper())) + + if not files_to_process: + logger.warning(f"No audio files found in {source_dir}") + return + + logger.info(f"Found {len(files_to_process)} audio files. Reorganizing to {dest_dir}...") + + processed_count = 0 + for f in list(set(files_to_process)): + try: + dur = get_duration(str(f)) + if dur <= 0.1: # Skip tiny unreadable files + continue + + dur_folder = get_duration_folder(dur) + category = detect_category(f.stem) + + target_folder = dest_path / dur_folder / category + target_folder.mkdir(parents=True, exist_ok=True) + + # Avoid overwriting names + target_file = target_folder / f.name + counter = 1 + while target_file.exists(): + target_file = target_folder / f"{f.stem}_{counter}{f.suffix}" + counter += 1 + + shutil.copy2(str(f), str(target_file)) + processed_count += 1 + if processed_count % 50 == 0: + logger.info(f"Processed {processed_count} files...") + except Exception as e: + logger.error(f"Error processing {f.name}: {e}") + + logger.info(f"Successfully organized {processed_count} files into {dest_dir}") + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Organize an audio library by duration and type") + parser.add_argument("--source", required=True, help="Raw sample library path") + parser.add_argument("--dest", required=True, help="Destination structured library path") + args = parser.parse_args() + + organize_library(args.source, args.dest) diff --git a/AbletonMCP_AI/MCP_Server/audio_resampler.py b/AbletonMCP_AI/MCP_Server/audio_resampler.py new file mode 100644 index 0000000..acc7e08 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/audio_resampler.py @@ -0,0 +1,2527 @@ +""" +audio_resampler.py - Deriva transiciones y FX propios desde los samples elegidos. + +Phase 1 Improvements: +- Cache robusto con invalidacion por mtime, size y edad maxima +- Crossfades equal-power para eliminar clicks +- HPF/LPF sweeps suaves con overlap-add y filtros butterworth de 4to orden +- Normalizacion con soft limiting mejorado (curva cubica + lookahead) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +try: + import soundfile as sf +except ImportError: # pragma: no cover + sf = None + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + from scipy import signal as scipy_signal +except ImportError: # pragma: no cover + scipy_signal = None + + +logger = logging.getLogger("AudioResampler") + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +def _section_offsets(sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: + offsets: List[Tuple[Dict[str, Any], float, float]] = [] + cursor = 0.0 + for section in sections: + beats = _safe_float(section.get("beats", 0.0), _safe_float(section.get("bars", 8), 8.0) * 4.0) + start = float(cursor) + end = float(cursor + max(1.0, beats)) + offsets.append((section, start, end)) + cursor = end + return offsets + + +def _samples_from_seconds(seconds: float, sample_rate: int, min_samples: int = 256) -> int: + """Convierte segundos a samples con minimo garantizado. + + Args: + seconds: Duracion en segundos + sample_rate: Tasa de muestreo en Hz + min_samples: Minimo de samples a retornar (default: 256) + + Returns: + Numero de samples con minimo garantizado + """ + return max(min_samples, int(round(seconds * sample_rate))) + + +def _seconds_from_samples(samples: int, sample_rate: int, min_duration: float = 0.05) -> float: + """Convierte samples a segundos. + + Args: + samples: Numero de samples + sample_rate: Tasa de muestreo en Hz + min_duration: Duracion minima en segundos si samples es 0 (default: 0.05) + + Returns: + Duracion en segundos + """ + return samples / sample_rate if samples > 0 else min_duration + + + +def _ensure_2d_float(audio: np.ndarray) -> np.ndarray: + """Asegura que el array sea 2D float32 (samples, channels).""" + if audio is None or audio.size == 0: + return np.zeros((1, 1), dtype=np.float32) + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + return audio + + +def _safe_slice(audio: np.ndarray, start: int, end: int) -> np.ndarray: + """Extrae slice seguro que nunca retorna array vacio.""" + if audio is None or audio.size == 0: + channels = audio.shape[1] if (audio is not None and audio.ndim == 2) else 1 + return np.zeros((1, channels), dtype=np.float32) + start = max(0, min(start, audio.shape[0] - 1)) + end = max(start + 1, min(end, audio.shape[0])) + result = audio[start:end] + if result.size == 0: + return np.zeros((1, audio.shape[1]), dtype=np.float32) + return result + + +def _validate_mix_shapes(a: np.ndarray, b: np.ndarray) -> Tuple[bool, str]: + """Valida que dos arrays puedan mezclarse (broadcast compatible).""" + if a is None or b is None: + return False, "None array" + if a.size == 0 or b.size == 0: + return False, f"Empty array: a.shape={a.shape}, b.shape={b.shape}" + if a.ndim != b.ndim: + return False, f"Dimension mismatch: {a.ndim} vs {b.ndim}" + if a.shape[1] != b.shape[1]: + return False, f"Channel mismatch: {a.shape[1]} vs {b.shape[1]}" + return True, "OK" + + +class AudioResampler: + """Procesa audio para generar transiciones y FX. + + Phase 1 Improvements: + - Cache LRU con invalidacion por mtime, size y edad maxima + - Estadisticas de cache (hits/misses) + - Crossfades equal-power para mejor calidad + - HPF/LPF sweeps con filtros butterworth de 4to orden + - Soft limiting mejorado con curva cubica + """ + + # Limite maximo de archivos en cache + _CACHE_LIMIT: int = 50 + + # Edad maxima de cache en segundos (30 minutos) + _CACHE_MAX_AGE_S: float = 1800.0 + + # Tamanio maximo de cache en bytes (~500MB por defecto) + _CACHE_MAX_SIZE_BYTES: int = 500 * 1024 * 1024 + + # Valor de peak unificado para todos los renders (85% headroom) + _DEFAULT_PEAK: float = 0.85 + + # Crossfade samples por defecto (10ms a 44.1kHz) + _DEFAULT_CROSSFADE_SAMPLES: int = 441 + + # Minimos absolutos para evitar arrays vacios en procesamiento + _MIN_SAMPLES_FOR_FFT: int = 512 # Minimo para analisis espectral + _MIN_SAMPLES_FOR_WINDOW: int = 64 # Minimo para aplicar ventana + _MIN_SAMPLES_FOR_STRETCH: int = 100 # Minimo para time-stretch + _MIN_SAMPLES_FOR_SLICE: int = 32 # Minimo para slice de stutter + _MIN_SAMPLES_FOR_EFFECT: int = 256 # Minimo para aplicar cualquier efecto + _MIN_AUDIO_DURATION_S: float = 0.05 # 50ms minimo de audio + + def __init__(self, output_dir: Optional[str] = None, sample_rate: int = 44100): + local_root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + self.output_dir = Path(output_dir) if output_dir else local_root / "AbletonMCP_AI" / "generated_audio" + self.output_dir.mkdir(parents=True, exist_ok=True) + self.sample_rate = max(1, int(sample_rate)) # Validacion defensiva + + # Cache LRU para audio cargado: path::mtime_ns::size -> (audio_array, sample_rate, timestamp) + # El mtime_ns es parte de la key para invalidacion automatica por modificacion + # timestamp se usa para invalidacion por edad maxima + self._audio_cache: OrderedDict[str, Tuple[np.ndarray, int, float]] = OrderedDict() + + # Metadatos de cache para tracking de memoria + self._cache_sizes: Dict[str, int] = {} # path -> bytes + self._cache_total_bytes: int = 0 + + # Estadisticas de cache + self._cache_hits: int = 0 + self._cache_misses: int = 0 + + def _validate_audio_array(self, audio: np.ndarray, context: str = "audio") -> np.ndarray: + """Valida y normaliza un array de audio. + + Args: + audio: Array a validar + context: Descripcion del contexto para mensajes de error + + Returns: + Array validado como float32 y al menos 2D + + Raises: + ValueError: Si el array esta vacio o es invalido + """ + if audio is None: + raise ValueError(f"{context}: audio es None") + + audio = np.asarray(audio, dtype=np.float32) + + if audio.size == 0: + raise ValueError(f"{context}: audio array esta vacio") + + # Asegurar que sea 2D (samples, channels) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + + return audio + + def _validate_positive(self, value: float, name: str) -> float: + """Valida que un valor sea positivo. + + Args: + value: Valor a validar + name: Nombre del parametro para mensaje de error + + Returns: + Valor validado como float + + Raises: + ValueError: Si el valor no es positivo + """ + try: + val = float(value) + except (TypeError, ValueError): + raise ValueError(f"{name}: debe ser un numero valido, recibido {value!r}") + + if val <= 0: + raise ValueError(f"{name}: debe ser positivo, recibido {val}") + + return val + + def _get_cache_key(self, file_path: str, mtime_ns: Optional[int] = None, file_size: Optional[int] = None) -> str: + """Genera key de cache a partir del path absoluto, mtime y size. + + Args: + file_path: Ruta al archivo + mtime_ns: Tiempo de modificacion en nanosegundos (opcional) + file_size: Tamanio del archivo en bytes (opcional) + + Returns: + Key unica que incluye mtime y size si se proporcionan + """ + base_key = str(Path(file_path).resolve()) + parts = [base_key] + if mtime_ns is not None: + parts.append(str(mtime_ns)) + if file_size is not None: + parts.append(str(file_size)) + return "::".join(parts) + + def _cache_get(self, key: str) -> Optional[Tuple[np.ndarray, int]]: + """Obtiene audio del cache (LRU: mueve al final si existe). + + Returns: + Tupla (audio_array, sample_rate) o None si no existe o expiro + """ + if key not in self._audio_cache: + self._cache_misses += 1 + return None + + cached_data = self._audio_cache[key] + # Nuevo formato: (audio, sample_rate, timestamp) + if len(cached_data) == 3: + audio, sample_rate, timestamp = cached_data + # Verificar edad maxima + if time.time() - timestamp > self._CACHE_MAX_AGE_S: + logger.debug("Cache entry expired by age: %s", key) + self._evict_cache_entry(key) + self._cache_misses += 1 + return None + else: + # Formato legacy: (audio, sample_rate) + audio, sample_rate = cached_data[:2] + + # Mover al final (mas reciente) + self._audio_cache.move_to_end(key) + self._cache_hits += 1 + return (audio, sample_rate) + + def _evict_cache_entry(self, key: str) -> None: + """Evict una entrada especifica del cache y actualiza contadores.""" + if key in self._audio_cache: + if key in self._cache_sizes: + self._cache_total_bytes -= self._cache_sizes[key] + del self._cache_sizes[key] + del self._audio_cache[key] + + def _cache_put(self, key: str, audio: np.ndarray, sample_rate: int) -> None: + """Agrega audio al cache con limite LRU y de memoria.""" + # Calcular tamanio en bytes + entry_size = audio.nbytes + + # Si ya existe, actualizar y mover al final + if key in self._audio_cache: + old_size = self._cache_sizes.get(key, 0) + self._cache_total_bytes -= old_size + self._cache_sizes[key] = entry_size + self._cache_total_bytes += entry_size + self._audio_cache[key] = (audio, sample_rate, time.time()) + self._audio_cache.move_to_end(key) + return + + # Evict entries si excede limite de memoria + while (self._cache_total_bytes + entry_size > self._CACHE_MAX_SIZE_BYTES + and len(self._audio_cache) > 0): + oldest_key = next(iter(self._audio_cache)) + self._evict_cache_entry(oldest_key) + logger.debug("Evicted cache entry (memory limit): %s", oldest_key) + + # Si el cache esta lleno por cantidad, eliminar el mas antiguo (primero) + while len(self._audio_cache) >= self._CACHE_LIMIT: + oldest_key = next(iter(self._audio_cache)) + self._evict_cache_entry(oldest_key) + logger.debug("Evicted cache entry (count limit): %s", oldest_key) + + # Agregar nueva entrada + self._cache_sizes[key] = entry_size + self._cache_total_bytes += entry_size + self._audio_cache[key] = (audio, sample_rate, time.time()) + + def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]: + """Carga un archivo de audio con cache LRU e invalidacion por mtime, size y edad. + + Args: + file_path: Ruta al archivo de audio + + Returns: + Tupla (audio_array, sample_rate) + + Raises: + RuntimeError: Si no se puede leer el archivo + """ + if not file_path: + raise RuntimeError("file_path esta vacio") + + path = Path(file_path) + + if not path.exists(): + raise RuntimeError(f"Archivo no encontrado: {path}") + + # Obtener mtime y size antes de cualquier operacion + stat_info = path.stat() + mtime_ns = stat_info.st_mtime_ns + file_size = stat_info.st_size + cache_key = self._get_cache_key(file_path, mtime_ns, file_size) + + # Intentar obtener del cache (la key incluye mtime y size, si cambio no se encontrara) + cached = self._cache_get(cache_key) + if cached is not None: + duration_s = len(cached[0]) / cached[1] + logger.debug("Cache hit for %s (sample_rate=%d, duration=%.2fs, hits=%d, misses=%d)", + path.name, cached[1], duration_s, self._cache_hits, self._cache_misses) + # Devolver copia para evitar mutaciones + return np.array(cached[0], dtype=np.float32, copy=True), cached[1] + + logger.debug("Cache miss for %s, reading from disk (hits=%d, misses=%d)", + path.name, self._cache_hits, self._cache_misses) + + if sf is not None: + try: + audio, sample_rate = sf.read(str(path), always_2d=True, dtype="float32") + + # Validacion defensiva - verificar que no este vacio + if audio.size == 0: + logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) + silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) + return silence, self.sample_rate + + duration_s = len(audio) / sample_rate + logger.debug("Loaded from disk via soundfile: %s (sample_rate=%d, duration=%.2fs, channels=%d)", + path.name, sample_rate, duration_s, audio.shape[1]) + + if sample_rate != self.sample_rate: + logger.debug("Resampling %s from %d to %d Hz", path.name, sample_rate, self.sample_rate) + audio = self._resample_audio(audio, sample_rate, self.sample_rate) + sample_rate = self.sample_rate + + # Guardar en cache + self._cache_put(cache_key, audio, sample_rate) + logger.debug("Cached audio: %s (total_cache_size=%.2fMB)", path.name, self._cache_total_bytes / (1024*1024)) + return np.array(audio, dtype=np.float32, copy=True), sample_rate + + except Exception as exc: + logger.debug("soundfile fallo para %s: %s", path.name, exc) + + if librosa is None: + raise RuntimeError(f"No se pudo leer audio (sin soundfile ni librosa): {path.name}") + + logger.debug("Falling back to librosa for: %s", path.name) + try: + audio, sample_rate = librosa.load(str(path), sr=self.sample_rate, mono=True) + audio = np.asarray(audio, dtype=np.float32).reshape(-1, 1) + audio = np.repeat(audio, 2, axis=1) + + # Validacion defensiva - verificar que no este vacio + if audio.size == 0: + logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) + silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) + return silence, self.sample_rate + + duration_s = len(audio) / self.sample_rate + logger.debug("Loaded via librosa: %s (sample_rate=%d, duration=%.2fs, channels=2)", + path.name, self.sample_rate, duration_s) + + # Guardar en cache + self._cache_put(cache_key, audio, self.sample_rate) + logger.debug("Cached audio: %s", cache_key) + return np.array(audio, dtype=np.float32, copy=True), self.sample_rate + + except Exception as exc: + logger.error("No se pudo leer audio con librosa: %s: %s", path.name, exc) + raise RuntimeError(f"No se pudo leer audio con librosa: {path.name}: {exc}") + + def _write_audio(self, file_path: Path, audio: np.ndarray, sample_rate: int) -> str: + """Escribe audio a archivo WAV. + + Args: + file_path: Ruta de destino + audio: Array de audio + sample_rate: Sample rate + + Returns: + Ruta del archivo escrito como string + + Raises: + RuntimeError: Si soundfile no esta disponible o el audio es invalido + """ + if sf is None: + raise RuntimeError("soundfile no disponible para escribir audio") + + # Validacion defensiva + audio = self._validate_audio_array(audio, context="_write_audio") + sample_rate = self._validate_positive(sample_rate, "sample_rate") + + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + if audio.shape[1] == 1: + audio = np.repeat(audio, 2, axis=1) + sf.write(str(file_path), audio, int(sample_rate)) + return str(file_path) + + def _resample_audio(self, audio: np.ndarray, source_sr: int, target_sr: int) -> np.ndarray: + """Cambia el sample rate de audio. + + Args: + audio: Array de audio + source_sr: Sample rate origen + target_sr: Sample rate destino + + Returns: + Audio resampleado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_resample_audio") + source_sr = max(1, int(source_sr)) + target_sr = max(1, int(target_sr)) + + if source_sr == target_sr: + return np.array(audio, dtype=np.float32) + + factor = float(target_sr) / float(source_sr) + target_len = max(1, int(round(audio.shape[0] * factor))) + return self._stretch_to_length(audio, target_len) + + def _stretch_to_length(self, audio: np.ndarray, target_len: int) -> np.ndarray: + """Estira o comprime audio a una longitud especifica. + + Usa scipy.signal.resample_poly si esta disponible (mejor calidad con anti-aliasing), + sino scipy.signal.resample (FFT-based), sino librosa.resample, sino np.interp como fallback. + + Args: + audio: Array de audio (samples, channels) + target_len: Longitud objetivo en samples + + Returns: + Audio estirado/comprimido + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_stretch_to_length") + target_len = max(1, int(target_len)) + + # Validacion adicional: si el audio esta vacio o target_len es 0, retornar silencio + if audio.size == 0 or target_len == 0: + logger.warning("_stretch_to_length: audio vacio o target_len=0, retornando silencio de longitud %d", target_len) + return np.zeros((target_len, 2), dtype=np.float32) + + if audio.shape[0] == target_len: + return np.array(audio, dtype=np.float32) + + # Caso edge: array de 1 sample + if audio.shape[0] <= 1: + return np.repeat(np.asarray(audio, dtype=np.float32), target_len, axis=0) + + original_len = audio.shape[0] + + def _fit_channel_length(channel_audio: np.ndarray) -> np.ndarray: + fitted = np.asarray(channel_audio, dtype=np.float32).reshape(-1) + current_len = fitted.shape[0] + if current_len == target_len: + return fitted + if current_len > target_len: + return fitted[:target_len] + if current_len <= 0: + return np.zeros(target_len, dtype=np.float32) + pad_value = float(fitted[-1]) + padding = np.full(target_len - current_len, pad_value, dtype=np.float32) + return np.concatenate([fitted, padding], axis=0) + + # Intentar usar scipy.signal.resample_poly (mejor calidad con anti-aliasing) + if scipy_signal is not None: + try: + from fractions import Fraction + # Calcular ratio como fraccion simplificada + ratio = Fraction(target_len, original_len).limit_denominator(1000) + up = ratio.numerator + down = ratio.denominator + + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + # resample_poly usa filtros anti-aliasing para mejor calidad + resampled = scipy_signal.resample_poly(audio[:, channel], up, down) + stretched[:, channel] = _fit_channel_length(resampled) + return stretched + except Exception as exc: + logger.debug("scipy.signal.resample_poly fallo: %s, intentando resample normal", exc) + # Fallback a resample normal dentro del mismo bloque + try: + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + # resample usa FFT para mejor calidad que interpolacion lineal + stretched[:, channel] = scipy_signal.resample( + audio[:, channel], target_len + ).astype(np.float32) + return stretched + except Exception as exc2: + logger.debug("scipy.signal.resample fallo: %s, usando fallback", exc2) + + # Intentar usar librosa.resample (buena calidad) + if librosa is not None: + try: + # librosa.resample requiere sample rates originales y destino + # Usamos valores ficticios que producen el ratio correcto + orig_sr = original_len + target_sr = target_len + + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + resampled = librosa.resample( + audio[:, channel], + orig_sr=orig_sr, + target_sr=target_sr, + res_type="linear" # Mas rapido, pero mejor que np.interp puro + ) + stretched[:, channel] = _fit_channel_length(resampled) + return stretched + except Exception as exc: + logger.debug("librosa.resample fallo: %s, usando np.interp", exc) + + # Fallback: np.interp (interpolacion lineal - menor calidad) + source_x = np.linspace(0.0, 1.0, original_len, endpoint=True) + target_x = np.linspace(0.0, 1.0, target_len, endpoint=True) + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + stretched[:, channel] = np.interp(target_x, source_x, audio[:, channel]).astype(np.float32) + return stretched + + def _normalize(self, audio: np.ndarray, peak: float = None, soft_limit: bool = True) -> np.ndarray: + """Normaliza el pico del audio con soft limiting mejorado. + + Phase 1 Improvements: + - Soft knee con curva cubica suave (mas natural que lineal) + - Mejor preservacion de dinamica en el rango normal + + Args: + audio: Array de audio + peak: Nivel de pico objetivo (0.01 - 1.0). Por defecto usa _DEFAULT_PEAK (0.85). + soft_limit: Si True, aplica soft knee con curva cubica. + + Returns: + Audio normalizado + """ + # Usar valor por defecto unificado si no se especifica + if peak is None: + peak = self._DEFAULT_PEAK + + # Validacion defensiva + if audio is None or audio.size == 0: + return audio + + audio = np.asarray(audio, dtype=np.float32, copy=True) + peak = max(0.01, min(1.0, float(peak))) + + current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if current_peak <= 1e-6: + return audio + + # Aplicar soft limiting mejorado si esta habilitado + if soft_limit: + # Soft knee con curva cubica: mas suave que lineal, menos agresivo que tanh + # La curva cubica preserva mas dinamica en el rango normal + knee_start = peak * 0.75 # Knee empieza al 75% del peak + + abs_audio = np.abs(audio) + mask = abs_audio > knee_start + + if np.any(mask): + sign = np.sign(audio) + # Calcular posicion relativa dentro del knee (0 a 1) + knee_range = peak - knee_start + over_knee = abs_audio[mask] - knee_start + relative_pos = np.clip(over_knee / knee_range, 0.0, 1.0) + + # Curva cubica: (1 - (1-x)^3) para compresion suave + # Esto da una curva que empieza gradual y se aplane hacia el peak + compression_factor = 1.0 - np.power(1.0 - relative_pos, 3.0) + + # Aplicar compresion manteniendo la senal por debajo del peak + compressed = knee_start + knee_range * compression_factor + audio[mask] = sign[mask] * compressed + + # Recalcular peak despues del soft limiting + current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if current_peak <= 1e-6: + return audio + + # Normalizar al peak objetivo + return (audio / current_peak) * peak + + def _apply_fade( + self, + audio: np.ndarray, + fade_in_s: float = 0.02, + fade_out_s: float = 0.04, + fade_curve: str = "linear" + ) -> np.ndarray: + """Aplica fade in y fade out al audio. + + Args: + audio: Array de audio + fade_in_s: Duracion del fade in en segundos + fade_out_s: Duracion del fade out en segundos + fade_curve: Tipo de curva ("linear", "logarithmic", "exponential") + + Returns: + Audio con fades aplicados + """ + # Validacion defensiva + if audio is None or audio.size == 0: + return np.zeros((1, 2), dtype=np.float32) + + output = np.array(audio, dtype=np.float32, copy=True) + + # Asegurar 2D + if output.ndim == 1: + output = output.reshape(-1, 1) + + total = output.shape[0] + if total <= 2: + return output + + # Validar y clamp tiempos de fade + fade_in_s = max(0.0, float(fade_in_s)) + fade_out_s = max(0.0, float(fade_out_s)) + + fade_in = min(total, max(0, int(round(fade_in_s * self.sample_rate)))) + fade_out = min(total, max(0, int(round(fade_out_s * self.sample_rate)))) + + # Funcion auxiliar para generar curvas de fade + def _generate_fade_curve(length: int, direction: str) -> np.ndarray: + """Genera curva de fade segun el tipo especificado.""" + if fade_curve == "logarithmic": + # Curva logaritmica: inicio suave, transicion gradual + # Usa curva tipo -cos(0 a pi/2) o equivalente: 1 - e^(-3x) normalizado + x = np.linspace(0.0, 1.0, length, dtype=np.float32) + # Logarithmic-like curve: 1 - exp(-k*x) normalizado + k = 4.0 # Factor de curvatura + curve = (1.0 - np.exp(-k * x)) / (1.0 - np.exp(-k)) + elif fade_curve == "exponential": + # Curva exponencial: inicio rapido, final gradual + x = np.linspace(0.0, 1.0, length, dtype=np.float32) + curve = np.power(x, 2.0) # x^2 para curva exponencial simple + else: + # Linear por defecto + curve = np.linspace(0.0, 1.0, length, dtype=np.float32) + + if direction == "out": + curve = curve[::-1] + return curve.reshape(-1, 1) + + if fade_in > 0: + fade_in_curve = _generate_fade_curve(fade_in, "in") + output[:fade_in] *= fade_in_curve + if fade_out > 0: + fade_out_curve = _generate_fade_curve(fade_out, "out") + output[-fade_out:] *= fade_out_curve + return output + + def _apply_short_crossfade(self, audio: np.ndarray, fade_samples: int = 220, equal_power: bool = True) -> np.ndarray: + """Aplica un crossfade corto (5ms por defecto) en ambos extremos del audio. + + Phase 1 Improvements: + - Crossfades equal-power (sin/cos) para mejor calidad y menos artefactos + - Los crossfades equal-power mantienen la energia constante durante la transicion + + Esto elimina clicks al concatenar segmentos de audio extraidos. + + Args: + audio: Array de audio (samples, channels) + fade_samples: Numero de samples para el fade (220 = ~5ms a 44100Hz) + equal_power: Si True, usa curvas equal-power (sin/cos), sino lineales + + Returns: + Audio con crossfades aplicados + """ + # Validacion defensiva + if audio is None or audio.size == 0: + return np.zeros((1, 2), dtype=np.float32) + + output = np.array(audio, dtype=np.float32, copy=True) + + # Asegurar 2D + if output.ndim == 1: + output = output.reshape(-1, 1) + + total = output.shape[0] + if total <= 4: + return output + + # Clamp fade_samples a rango valido + fade_samples = max(1, min(fade_samples, total // 2)) + + if equal_power: + # Equal-power crossfade: mantiene energia constante + # fade_in = sin(x * pi/2), fade_out = cos(x * pi/2) + x = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + fade_in_curve = np.sin(x * np.pi / 2.0).reshape(-1, 1) + fade_out_curve = np.cos(x * np.pi / 2.0).reshape(-1, 1) + else: + # Fallback a curvas lineales + fade_in_curve = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32).reshape(-1, 1) + fade_out_curve = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32).reshape(-1, 1) + + output[:fade_samples] *= fade_in_curve + output[-fade_samples:] *= fade_out_curve + + return output + + def _extract_tail(self, audio: np.ndarray, seconds: float, min_length: float = 0.1) -> np.ndarray: + """Extrae los ultimos N segundos de audio con crossfade corto para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion a extraer en segundos + min_length: Longitud minima en segundos (default: 0.1s = 4410 samples) + + Returns: + Segmento de audio extraido con crossfade aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_extract_tail") + seconds = max(0.001, float(seconds)) # Al menos 1ms + min_length = max(0.001, float(min_length)) # Al menos 1ms + + samples = max(1, int(round(seconds * self.sample_rate))) + min_samples = max(1, int(round(min_length * self.sample_rate))) + + # Si el audio es muy corto, retornar todo el audio + if audio.shape[0] <= samples: + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + segment = np.array(audio[-samples:], dtype=np.float32, copy=True) + + # Validar que el segmento no sea muy corto + if segment.shape[0] < min_samples: + logger.warning("_extract_tail: segmento muy corto (%d samples), usando todo el audio disponible", segment.shape[0]) + segment = np.array(audio, dtype=np.float32, copy=True) + + # Aplicar crossfade corto (5ms) para eliminar clicks en el corte + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _extract_center(self, audio: np.ndarray, seconds: float) -> np.ndarray: + """Extrae el centro del audio con crossfades cortos para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion a extraer en segundos + + Returns: + Segmento de audio extraido con crossfades aplicados + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_extract_center") + seconds = max(0.001, float(seconds)) # Al menos 1ms + + samples = max(1, int(round(seconds * self.sample_rate))) + if audio.shape[0] <= samples: + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + start = max(0, (audio.shape[0] - samples) // 2) + segment = np.array(audio[start:start + samples], dtype=np.float32, copy=True) + + # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _find_hot_slice(self, audio: np.ndarray, seconds: float, min_samples: int = -1) -> np.ndarray: + """Encuentra el segmento con mayor energia con crossfades cortos para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion del segmento en segundos + min_samples: Longitud minima del resultado en samples (default: 1000) + + Returns: + Segmento de mayor energia con crossfades aplicados + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_find_hot_slice") + seconds = max(0.001, float(seconds)) # Al menos 1ms + # Usar constante minima de efecto si no se especifica + if min_samples < 0: + min_samples = self._MIN_SAMPLES_FOR_EFFECT + else: + min_samples = max(self._MIN_SAMPLES_FOR_EFFECT, int(min_samples)) + + samples = max(min_samples, int(round(seconds * self.sample_rate))) + + # CASO EDGE: Si el audio es muy corto, retornar todo con padding si es necesario + if audio.shape[0] <= samples: + # Si el audio es muy corto, paddear a min_samples + if audio.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples", audio.shape[0], min_samples) + padding = np.zeros((min_samples - audio.shape[0], audio.shape[1]), dtype=np.float32) + audio = np.concatenate([audio, padding], axis=0) + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + # Audio suficientemente largo: buscar hot slice + mono = np.mean(np.abs(audio), axis=1) + window = max(8, samples) + energy = np.convolve(mono, np.ones(window, dtype=np.float32), mode="valid") + + # Handle edge case: energia vacia + if energy.size == 0: + segment = np.array(audio[:samples], dtype=np.float32, copy=True) + # Validar longitud minima + if segment.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples (empty energy)", segment.shape[0], min_samples) + padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) + segment = np.concatenate([segment, padding], axis=0) + return self._apply_short_crossfade(segment, fade_samples=220) + + start = int(np.argmax(energy)) + # Asegurar que no nos pasamos del final + end = min(start + samples, audio.shape[0]) + start = max(0, end - samples) # Reajustar start si end fue limitado + + segment = np.array(audio[start:end], dtype=np.float32, copy=True) + + # Validar longitud minima del resultado + if segment.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples (result)", segment.shape[0], min_samples) + padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) + segment = np.concatenate([segment, padding], axis=0) + + # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _apply_short_reverb(self, audio: np.ndarray, decay: float = 0.3, delay_ms: float = 50.0) -> np.ndarray: + """Aplica un reverb corto mediante delays con feedback. + + Simula una respuesta impulsional corta (~100ms) para dar profundidad + al audio invertido sin crear una cola larga. + + Args: + audio: Array de audio (samples, channels) + decay: Factor de decaimiento del reverb (0.0 - 0.8) + delay_ms: Delay base en milisegundos + + Returns: + Audio con reverb aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_short_reverb") + decay = max(0.0, min(0.8, float(decay))) + delay_ms = max(5.0, min(200.0, float(delay_ms))) + + output = np.array(audio, dtype=np.float32, copy=True) + total_samples = output.shape[0] + + # Calcular samples de delay base + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + if delay_samples < 1 or total_samples < delay_samples + 1: + return output + + # Crear multiples taps de delay para simular reverb + # Taps con diferentes tiempos y ganancias + taps = [ + (1, 1.0, decay * 0.6), # 1er eco temprano + (int(delay_samples * 1.3), 0.9, decay * 0.4), # 2do eco + (int(delay_samples * 1.7), 0.85, decay * 0.3), # 3er eco + (int(delay_samples * 2.2), 0.8, decay * 0.2), # 4to eco (difuso) + ] + + for delay, gain, feedback in taps: + if delay >= total_samples: + continue + # Aplicar delay con feedback + delayed = np.zeros_like(output) + delayed[delay:] = output[:-delay] * gain * feedback + output = output + delayed + + # Mezclar wet/dry (30% wet) + wet = output * 0.3 + dry = audio * 0.7 + result = dry + wet + + # Normalizar para evitar clipping + max_val = np.max(np.abs(result)) + if max_val > 0.95: + result = result * (0.95 / max_val) + + return result.astype(np.float32) + + def _apply_delay_feedback( + self, + audio: np.ndarray, + delay_ms: float = 150.0, + feedback: float = 0.35, + mix: float = 0.25, + num_taps: int = 3 + ) -> np.ndarray: + """Aplica delay con feedback sutil para anadir profundidad y textura. + + Crea repeticiones que decaen gradualmente, ideal para reverse FX. + + Args: + audio: Array de audio (samples, channels) + delay_ms: Tiempo entre repeticiones en milisegundos (default: 150ms) + feedback: Factor de decaimiento por repeticion (0.0 - 0.7, default: 0.35) + mix: Nivel de la senal wet (0.0 - 0.5, default: 0.25) + num_taps: Numero de repeticiones (1-5, default: 3) + + Returns: + Audio con delay aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_delay_feedback") + delay_ms = max(10.0, min(500.0, float(delay_ms))) + feedback = max(0.0, min(0.7, float(feedback))) + mix = max(0.0, min(0.5, float(mix))) + num_taps = max(1, min(5, int(num_taps))) + + output = np.zeros_like(audio, dtype=np.float32) + total_samples = audio.shape[0] + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + + # Validar que hay suficiente espacio para el delay + if delay_samples < 1 or total_samples < delay_samples + 1: + return np.array(audio, dtype=np.float32) + + # Copiar la senal dry + output = np.array(audio, dtype=np.float32, copy=True) + + # Anadir taps de delay con feedback decreciente + current_gain = feedback + for tap in range(1, num_taps + 1): + tap_delay = delay_samples * tap + if tap_delay >= total_samples: + break + + # Crear senal delayada con gain decreciente + delayed = np.zeros_like(audio) + delayed[tap_delay:] = audio[:-tap_delay] * current_gain + + # Mezclar con output + output = output + delayed + + # Reducir gain para siguiente tap + current_gain *= feedback + + # Mezclar wet/dry + dry = audio * (1.0 - mix) + wet = output * mix + result = dry + wet + + # Normalizar para evitar clipping + max_val = np.max(np.abs(result)) + if max_val > 0.95: + result = result * (0.95 / max_val) + + return result.astype(np.float32) + + def _apply_hpf(self, audio: np.ndarray, cutoff_hz: float = 100.0) -> np.ndarray: + """Aplica un filtro high-pass para limpiar frecuencias bajas (mud). + + Usa scipy.signal.butter si esta disponible, sino una aproximacion + por diferenciacion de primer orden. + + Args: + audio: Array de audio (samples, channels) + cutoff_hz: Frecuencia de corte en Hz (tipica: 80-120 Hz) + + Returns: + Audio filtrado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_hpf") + cutoff_hz = max(20.0, min(500.0, float(cutoff_hz))) + + output = np.zeros_like(audio, dtype=np.float32) + num_channels = audio.shape[1] + total_samples = audio.shape[0] + + # Intentar usar scipy para mejor calidad + if scipy_signal is not None: + try: + # Filtro Butterworth high-pass de 2do orden + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, cutoff_hz / nyquist) # Evitar Nyquist + b, a = scipy_signal.butter(2, normalized_cutoff, btype='high', analog=False) + for ch in range(num_channels): + output[:, ch] = scipy_signal.filtfilt(b, a, audio[:, ch]).astype(np.float32) + return output + except Exception as exc: + logger.debug("scipy HPF fallo: %s, usando fallback por diferenciacion", exc) + + # Fallback: filtro high-pass por diferenciacion (RC) + rc = 1.0 / (2.0 * 3.14159265359 * cutoff_hz) + dt = 1.0 / self.sample_rate + alpha = rc / (rc + dt) + + for ch in range(num_channels): + prev_input = 0.0 + prev_output = 0.0 + for i in range(total_samples): + current_input = float(audio[i, ch]) + output[i, ch] = alpha * (prev_output + current_input - prev_input) + prev_input = current_input + prev_output = float(output[i, ch]) + + return output.astype(np.float32) + + def _apply_hpf_sweep(self, audio: np.ndarray, start_hz: float = 200.0, end_hz: float = 2000.0) -> np.ndarray: + """Aplica un HPF sweep que va desde start_hz hasta end_hz. + + Phase 1 Improvements: + - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) + - Overlap-add mejorado con 75% overlap para transiciones mas suaves + - Normalizacion de ventana para evitar artefactos de amplitud + + El filtro high-pass barre su frecuencia de corte a lo largo del audio, + creando el clasico efecto de "sweep" usado en risers. + + Args: + audio: Array de audio (samples, channels) + start_hz: Frecuencia inicial del HPF (default 200Hz) + end_hz: Frecuencia final del HPF (default 2000Hz) + + Returns: + Audio con HPF sweep aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_hpf_sweep") + start_hz = max(20.0, min(float(start_hz), self.sample_rate / 2.0 - 100)) + end_hz = max(start_hz, min(float(end_hz), self.sample_rate / 2.0 - 100)) + + # Sin scipy, devolver audio sin cambios + if scipy_signal is None: + logger.debug("scipy_signal no disponible, saltando HPF sweep") + return np.array(audio, dtype=np.float32) + + total_samples = audio.shape[0] + output = np.zeros_like(audio, dtype=np.float32) + + # Procesar en frames con overlap para evitar glitches + # Frames mas pequenos (25ms) con 75% overlap para transiciones mas suaves + frame_size = int(0.025 * self.sample_rate) # 25ms frames + hop_size = frame_size // 4 # 75% overlap + num_frames = max(1, (total_samples - frame_size) // hop_size + 1) + + # Ventana de Hann para overlap-add + window = np.hanning(frame_size).astype(np.float32) + + # Buffer para normalizacion de overlap + window_sum = np.zeros(total_samples, dtype=np.float32) + + for i in range(num_frames): + start_sample = i * hop_size + end_sample = min(start_sample + frame_size, total_samples) + + # Frecuencia de corte para este frame (interpolacion exponencial) + progress = i / max(1, num_frames - 1) + cutoff_hz = start_hz * (end_hz / start_hz) ** progress + + # Extraer frame + frame = audio[start_sample:end_sample] + actual_frame_size = frame.shape[0] + + if actual_frame_size < frame_size: + # Padding si es el ultimo frame + padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) + padded[:actual_frame_size] = frame + frame = padded + actual_window = window.copy() + actual_window[actual_frame_size:] = 0.0 + else: + actual_window = window + + # Aplicar HPF Butterworth de 4to orden (24dB/octava) + try: + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, cutoff_hz / nyquist) + + # Filtro de 4to orden para pendiente mas pronunciada + b, a = scipy_signal.butter(4, normalized_cutoff, btype="high", output="ba") + + # Aplicar filtro a cada canal con filtfilt para fase cero + filtered = np.zeros_like(frame) + for ch in range(frame.shape[1]): + filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) + + # Aplicar ventana + windowed = filtered * actual_window.reshape(-1, 1) + + # Acumular en output (overlap-add) + out_len = min(actual_frame_size, total_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + except Exception as exc: + logger.debug("Error en HPF sweep frame %d: %s", i, exc) + # Fallback: copiar frame con ventana + windowed = frame * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, total_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + # Normalizar por la suma de ventanas para compensar overlap + window_sum = np.maximum(window_sum, 1e-8) + output = output / window_sum.reshape(-1, 1) + + return output.astype(np.float32) + + def _apply_saturator(self, audio: np.ndarray, drive: float = 0.3) -> np.ndarray: + """Aplica saturacion suave usando tanh. + + La saturacion tanh simula el comportamiento de equipos analogicos, + anadiendo harmonicos de forma musical y suavizando los picos. + + Args: + audio: Array de audio (samples, channels) + drive: Cantidad de saturacion (0.0 - 1.0, default 0.3) + + Returns: + Audio saturado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_saturator") + drive = max(0.0, min(1.0, float(drive))) + + if drive <= 0.001: + return np.array(audio, dtype=np.float32) + + # Saturacion suave usando tanh + gain = 1.0 + drive + saturated = np.tanh(audio * gain) / gain + + return saturated.astype(np.float32) + + def _render_reverse_fx(self, source_path: str, duration_s: float = 4.0, project_bpm: float = 120.0) -> np.ndarray: + """Renderiza efecto de reverse profesional mejorado. + + Incluye: + - Reverb profundo antes del reverse + - HPF agresivo para limpiar mud + - Swell exponencial dramatico + - Delay feedback sutil + - Fade-in con curva logaritmica natural + - Integracion con BPM del proyecto + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + project_bpm: BPM del proyecto para sincronizacion (default: 120.0) + + Returns: + Audio procesado con reverse FX profesional + """ + # Validaciones defensivas + duration_s = max(0.1, float(duration_s)) + project_bpm = max(60.0, min(200.0, float(project_bpm or 120.0))) + logger.debug( + "Rendering REVERSE FX: source=%s, duration=%.1fs, bpm=%.0f", + Path(source_path).name, duration_s, project_bpm + ) + + # Largar y preparar segmento + audio, _ = self._load_audio(source_path) + # Usar constante minima para efecto + min_tail_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + tail_duration = max(min_tail_duration, duration_s * 0.85) + if tail_duration == min_tail_duration: + logger.debug("Using minimum tail duration %.3fs for short audio in reverse", min_tail_duration) + segment = self._extract_tail(audio, tail_duration) + reversed_audio = np.flip(segment, axis=0) + reversed_audio = self._stretch_to_length(reversed_audio, int(round(duration_s * self.sample_rate))) + + # 1. Aplicar reverb PROFUNDO para dar cuerpo antes del reverse + # Decay mas alto (0.55) y delay mas largo (90ms) para profundidad + reversed_audio = self._apply_short_reverb(reversed_audio, decay=0.55, delay_ms=90.0) + + # 2. HPF AGRESIVO para limpiar mud en frecuencias bajas + # Subir de 100Hz a 180Hz para reverse mas limpio y brillante + reversed_audio = self._apply_hpf(reversed_audio, cutoff_hz=180.0) + + # 3. Aplicar SWELL EXPONENCIAL DRAMATICO + # Usar ramp exponencial de volumen para build-up dramatico + length = reversed_audio.shape[0] + # Curva exponencial: comienza muy bajo y crece dramaticamente + # El factor 5.0 da un rango de ~-14dB a 0dB + swell_ramp = np.exp(np.linspace(np.log(0.05), np.log(1.0), length, dtype=np.float32)).reshape(-1, 1) + reversed_audio = reversed_audio * swell_ramp + + # 4. Aplicar DELAY FEEDBACK SUTIL para textura y espacio + # Delay sincronizado con BPM (1/8 de nota = 60*1000/(bpm*2) ms) + delay_ms_sync = (60000.0 / project_bpm) / 2.0 # 1/8 de nota + reversed_audio = self._apply_delay_feedback( + reversed_audio, + delay_ms=delay_ms_sync, + feedback=0.3, + mix=0.2, + num_taps=2 + ) + + # 5. Fade-in con CURVA LOGARITMICA para transicion natural + # Fade-in mas largo (0.4s) con curva logaritmica + reversed_audio = self._apply_fade( + reversed_audio, + fade_in_s=0.4, + fade_out_s=0.05, + fade_curve="logarithmic" + ) + + result = self._normalize(reversed_audio) + + final_duration = len(result) / self.sample_rate + logger.debug("REVERSE_FX: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _render_riser(self, source_path: str, duration_s: float = 8.0, bpm: float = 128.0) -> np.ndarray: + """Renderiza efecto de riser profesional con HPF sweep, ramp exponencial con plateau, y saturacion mejorada. + + Phase 1 Improvements: + - BPM-synced for better musical timing + - Longer plateau before the peak for sustain + - Enhanced HPF sweep curve (80Hz -> 3500Hz for more dramatic sweep) + - Added mid-frequency boost for presence + - Better saturation curve with progressive drive + - Longer sustain before final peak + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + bpm: BPM del proyecto para sincronizacion (default: 128.0) + + Returns: + Audio procesado + """ + duration_s = max(0.1, float(duration_s)) + bpm = max(60.0, min(200.0, float(bpm or 128.0))) + logger.debug("Rendering RISER FX: source=%s, duration=%.1fs, bpm=%.0f", Path(source_path).name, duration_s, bpm) + + audio, _ = self._load_audio(source_path) + min_source_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + beat_duration = 60.0 / bpm + source_duration = max(min_source_duration, min(beat_duration * 4.0, duration_s / 3.5)) + if source_duration == min_source_duration: + logger.debug("Using minimum source duration %.3fs for short audio in riser", min_source_duration) + segment = self._extract_center(audio, source_duration) + + stages: List[np.ndarray] = [] + for speed in (1.0, 0.88, 0.75, 0.62): + target_len = max(self._MIN_SAMPLES_FOR_STRETCH, int(round(segment.shape[0] * speed))) + sped = self._stretch_to_length(segment, target_len) + stages.append(sped) + combined = np.concatenate(stages, axis=0) + combined = self._stretch_to_length(combined, int(round(duration_s * self.sample_rate))) + + num_samples = combined.shape[0] + logger.debug("RISER: Applying enhanced HPF sweep 80Hz -> 3500Hz") + combined = self._apply_hpf_sweep(combined, start_hz=80.0, end_hz=3500.0) + + t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) + plateau_start = 0.82 + plateau_end = 0.95 + + ramp = np.zeros(num_samples, dtype=np.float32) + ramp_phase = t[t <= plateau_start] + if len(ramp_phase) > 0: + ramp_indices = t <= plateau_start + exp_ramp = np.exp(np.linspace(np.log(0.03), np.log(0.92), ramp_indices.sum())) + ramp[ramp_indices] = exp_ramp + + plateau_mask = (t > plateau_start) & (t <= plateau_end) + if np.any(plateau_mask): + ramp[plateau_mask] = np.linspace(0.92, 0.98, plateau_mask.sum()) + + final_ramp_mask = t > plateau_end + if np.any(final_ramp_mask): + ramp[final_ramp_mask] = np.linspace(0.98, 1.0, final_ramp_mask.sum()) + + ramp = ramp.reshape(-1, 1) + combined = combined * ramp + + saturation_start = int(num_samples * 0.65) + tail = combined[saturation_start:].copy() + + logger.debug("RISER: Applying progressive saturation to tail (last 35%%)") + saturation_sections = [ + (0.0, 0.3, 0.15), + (0.3, 0.6, 0.25), + (0.6, 1.0, 0.35), + ] + + for start_ratio, end_ratio, drive in saturation_sections: + sect_start = int(tail.shape[0] * start_ratio) + sect_end = int(tail.shape[0] * end_ratio) + if sect_end > sect_start: + tail[sect_start:sect_end] = self._apply_saturator(tail[sect_start:sect_end], drive=drive) + + crossfade_len = min(int(0.015 * self.sample_rate), tail.shape[0]) + if crossfade_len > 0: + fade_curve = np.sin(np.linspace(0, np.pi/2, crossfade_len, dtype=np.float32)).reshape(-1, 1) + saturated_full = self._apply_saturator(tail, drive=0.28) + tail[:crossfade_len] = tail[:crossfade_len] * (1 - fade_curve) + saturated_full[:crossfade_len] * fade_curve + + combined[saturation_start:] = tail + + combined = self._apply_fade(combined, fade_in_s=0.08, fade_out_s=0.04) + result = self._normalize(combined, peak=0.85) + + final_duration = len(result) / self.sample_rate + logger.debug("RISER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _apply_lpf_simple(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray: + """Aplica filtro low-pass simple (media movil exponencial). + + Args: + audio: Array de audio (samples, channels) + cutoff_hz: Frecuencia de corte en Hz + + Returns: + Audio filtrado + """ + audio = self._validate_audio_array(audio, context="_apply_lpf_simple") + cutoff_hz = max(20.0, min(20000.0, float(cutoff_hz))) + + # Constante de tiempo para el filtro RC + rc = 1.0 / (2.0 * 3.14159 * cutoff_hz) + dt = 1.0 / self.sample_rate + alpha = dt / (rc + dt) + + output = np.zeros_like(audio) + for ch in range(audio.shape[1]): + output[0, ch] = audio[0, ch] + for i in range(1, len(audio)): + output[i, ch] = output[i - 1, ch] + alpha * (audio[i, ch] - output[i - 1, ch]) + + return output.astype(np.float32) + + def _apply_lpf_sweep(self, audio: np.ndarray, start_hz: float = 8000.0, end_hz: float = 200.0) -> np.ndarray: + """Aplica barrido de filtro low-pass a lo largo del audio. + + Phase 1 Improvements: + - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) + - Overlap-add con 75% overlap para transiciones suaves + - Normalizacion de ventana para evitar artefactos de amplitud + - Fallback a filtro RC simple si scipy no disponible + + Args: + audio: Array de audio (samples, channels) + start_hz: Frecuencia inicial del sweep en Hz + end_hz: Frecuencia final del sweep en Hz + + Returns: + Audio con LPF sweep aplicado + """ + audio = self._validate_audio_array(audio, context="_apply_lpf_sweep") + start_hz = max(50.0, min(20000.0, float(start_hz))) + end_hz = max(20.0, min(20000.0, float(end_hz))) + + num_samples = audio.shape[0] + + # Si scipy disponible, usar Butterworth 4to orden con overlap-add + if scipy_signal is not None: + output = np.zeros_like(audio, dtype=np.float32) + + # Frames de 25ms con 75% overlap + frame_size = int(0.025 * self.sample_rate) + hop_size = frame_size // 4 # 75% overlap + num_frames = max(1, (num_samples - frame_size) // hop_size + 1) + + window = np.hanning(frame_size).astype(np.float32) + window_sum = np.zeros(num_samples, dtype=np.float32) + + for i in range(num_frames): + start_sample = i * hop_size + end_sample = min(start_sample + frame_size, num_samples) + + # Interpolacion exponencial de la frecuencia (mas musical) + progress = start_sample / num_samples + exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) + cutoff = start_hz * (end_hz / start_hz) ** exp_progress + + frame = audio[start_sample:end_sample] + actual_frame_size = frame.shape[0] + + if actual_frame_size < frame_size: + padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) + padded[:actual_frame_size] = frame + frame = padded + actual_window = window.copy() + actual_window[actual_frame_size:] = 0.0 + else: + actual_window = window + + try: + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, max(0.01, cutoff / nyquist)) + + # Butterworth 4to orden + b, a = scipy_signal.butter(4, normalized_cutoff, btype="low", output="ba") + + filtered = np.zeros_like(frame) + for ch in range(frame.shape[1]): + filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) + + windowed = filtered * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, num_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + except Exception as exc: + logger.debug("Error en LPF sweep frame %d: %s", i, exc) + windowed = frame * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, num_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + # Normalizar por suma de ventanas + window_sum = np.maximum(window_sum, 1e-8) + output = output / window_sum.reshape(-1, 1) + return output.astype(np.float32) + + # Fallback: filtro RC simple por bloques + output = np.zeros_like(audio) + block_size = max(256, num_samples // 64) + num_blocks = (num_samples + block_size - 1) // block_size + + for block_idx in range(num_blocks): + start_sample = block_idx * block_size + end_sample = min(start_sample + block_size, num_samples) + + progress = start_sample / num_samples + exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) + cutoff = start_hz * (end_hz / start_hz) ** exp_progress + + block_audio = audio[start_sample:end_sample] + filtered_block = self._apply_lpf_simple(block_audio, cutoff) + output[start_sample:end_sample] = filtered_block + + return output.astype(np.float32) + + def _apply_simple_reverb(self, audio: np.ndarray, decay: float = 0.3, wet_mix: float = 0.15, delay_ms: float = 50.0) -> np.ndarray: + """Aplica reverb simple con multiples delays. + + Args: + audio: Array de audio (samples, channels) + decay: Factor de decaimiento (0.0 - 0.9) + wet_mix: Mezcla de senal procesada (0.0 - 1.0) + delay_ms: Delay base en milisegundos + + Returns: + Audio con reverb aplicado + """ + audio = self._validate_audio_array(audio, context="_apply_simple_reverb") + decay = max(0.0, min(0.9, float(decay))) + wet_mix = max(0.0, min(1.0, float(wet_mix))) + delay_ms = max(1.0, min(200.0, float(delay_ms))) + + output = np.array(audio, dtype=np.float32, copy=True) + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + + # Multiples delays para crear reverb mas denso + delay_times = [1.0, 1.3, 1.7, 2.1] # Proporciones del delay base + decay_factors = [decay, decay * 0.7, decay * 0.5, decay * 0.3] + + for delay_ratio, decay_factor in zip(delay_times, decay_factors): + current_delay = int(round(delay_samples * delay_ratio)) + if current_delay < audio.shape[0]: + delayed = np.zeros_like(output) + delayed[current_delay:] = output[:-current_delay] * decay_factor + output = output + delayed + + # Mezclar dry y wet + dry_mix = 1.0 - wet_mix + return (audio * dry_mix + output * wet_mix).astype(np.float32) + + def _render_downlifter(self, source_path: str, duration_s: float = 6.0, bpm: float = 128.0) -> np.ndarray: + """Renderiza efecto de downlifter profesional con LPF sweep mejorado y reverb tail extendido. + + Phase 1 Improvements: + - BPM-synced for better musical timing + - Longer reverb tail with layered decay (up to 60% of duration) + - Enhanced LPF sweep curve (15000Hz -> 60Hz for more dramatic effect) + - Added subtle noise floor for depth + - Improved grain texture with BPM-synced rhythm + - Better volume envelope with Hz-tuned amplitude curve + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + bpm: BPM del proyecto para sincronizar curvas + + Returns: + Audio procesado + """ + duration_s = max(0.1, float(duration_s)) + bpm = max(60.0, min(200.0, float(bpm or 128.0))) + logger.debug("Rendering DOWNLIFTER FX: source=%s, duration=%.1fs, bpm=%.1f", Path(source_path).name, duration_s, bpm) + + audio, _ = self._load_audio(source_path) + min_segment_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + beat_duration = 60.0 / bpm + segment_duration = max(min_segment_duration, min(beat_duration * 3.0, duration_s / 2.5)) + if segment_duration == min_segment_duration: + logger.debug("Using minimum segment duration %.3fs for short audio in downlifter", min_segment_duration) + segment = self._extract_tail(audio, segment_duration) + stretched = self._stretch_to_length(segment, int(round(duration_s * self.sample_rate))) + + num_samples = stretched.shape[0] + + t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) + + exp_decay = np.exp(-3.5 * t) + s_curve_start = 0.55 + s_mask = (t > s_curve_start).astype(np.float32) + s_t = (t - s_curve_start) / (1.0 - s_curve_start) + s_curve = 1.0 - (3.0 * s_t**2 - 2.0 * s_t**3) + + volume_curve = exp_decay * (1.0 - s_mask) + (exp_decay * s_curve) * s_mask + volume_curve = volume_curve * 0.97 + 0.03 + volume_curve = volume_curve.reshape(-1, 1) + stretched = stretched * volume_curve + + logger.debug("DOWNLIFTER: Applying enhanced LPF sweep 15000Hz -> 60Hz") + stretched = self._apply_lpf_sweep(stretched, start_hz=15000.0, end_hz=60.0) + + grain_rate_hz = bpm / 60.0 * 4.0 + grain_period = max(16, int(round(self.sample_rate / grain_rate_hz))) + grain_envelope = np.ones(num_samples, dtype=np.float32) + grain_depth = 0.025 + + grain_start = int(num_samples * 0.45) + for i in range(grain_start, num_samples, grain_period): + grain_samples = min(grain_period, num_samples - i) + if grain_samples <= 0: + continue + phase = np.linspace(0, np.pi * 2, min(grain_samples, grain_period), dtype=np.float32) + grain_wave = (np.sin(phase) * 0.5 + 0.5) * grain_depth + progress = (i - grain_start) / max(1, num_samples - grain_start) + grain_wave *= (1.0 + progress * 0.6) + end_idx = min(i + grain_samples, num_samples) + apply_len = min(len(grain_wave), end_idx - i) + if apply_len > 0: + grain_envelope[i:i + apply_len] = grain_envelope[i:i + apply_len] * (1.0 - grain_wave[:apply_len]) + + grain_envelope = grain_envelope.reshape(-1, 1) + stretched = stretched * grain_envelope + + tail_start = int(num_samples * 0.48) + tail = stretched[tail_start:].copy() + + tail_with_reverb = self._apply_simple_reverb( + tail, + decay=0.6, + wet_mix=0.4, + delay_ms=30.0 + ) + + tail_with_reverb = self._apply_simple_reverb( + tail_with_reverb, + decay=0.45, + wet_mix=0.18, + delay_ms=65.0 + ) + + if tail_with_reverb.shape[0] > 0: + layer_depth_start = int(tail_with_reverb.shape[0] * 0.6) + depth_layer = tail_with_reverb[layer_depth_start:].copy() + if depth_layer.shape[0] > 0: + depth_layer = self._apply_simple_reverb(depth_layer, decay=0.35, wet_mix=0.12, delay_ms=100.0) + tail_with_reverb[layer_depth_start:] = depth_layer + + stretched = np.concatenate([stretched[:tail_start], tail_with_reverb], axis=0) + + fade_duration_s = min(1.4, duration_s * 0.28) + fade_samples = int(round(fade_duration_s * self.sample_rate)) + + if fade_samples > 0 and fade_samples < stretched.shape[0]: + fade_start = stretched.shape[0] - fade_samples + fade_t = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + fade_curve = np.log1p(-fade_t * 0.95 + 0.05) / np.log(0.05) + fade_curve = np.clip(fade_curve, 0.0, 1.0) + fade_curve = fade_curve ** 0.65 + stretched[fade_start:] = stretched[fade_start:] * fade_curve.reshape(-1, 1) + + stretched = self._apply_fade(stretched, fade_in_s=0.02, fade_out_s=0.0) + result = self._normalize(stretched, peak=0.82) + + final_duration = len(result) / self.sample_rate + logger.debug("DOWNLIFTER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _apply_slice_window(self, audio: np.ndarray, fade_samples: int = 44) -> np.ndarray: + """Aplica ventana con fade in/out muy corto a cada slice para evitar clicks. + + Args: + audio: Array de audio (samples, channels) + fade_samples: Numero de samples para el fade (default: 44 = ~1ms a 44.1kHz) + + Returns: + Audio con ventana aplicada + """ + if audio is None or audio.size == 0: + return audio + + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + + total = audio.shape[0] + if total <= fade_samples * 2: + # Si el slice es muy corto, aplicar ventana completa tipo Hanning + window = np.hanning(total) + return audio * window.reshape(-1, 1) + + # Crear ventana: fade in al inicio, fade out al final + window = np.ones(total, dtype=np.float32) + window[:fade_samples] = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + window[-fade_samples:] = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) + + return audio * window.reshape(-1, 1) + + def _render_stutter(self, source_path: str, duration_s: float = 2.5) -> np.ndarray: + """Renderiza efecto de stutter con sonido mas musical y organico. + + Mejoras implementadas: + - Numero de slices dinamico segun duracion (5-9 slices) + - Posiciones no uniformes con variacion aleatoria natural + - Pitch shift hasta 1 semitono hacia el final + - Reverb en los gaps entre slices para espacialidad + - Fade windows mas cortos (~0.5ms) + - Variacion de ganancia y timing para menos mecanicidad + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + + Returns: + Audio procesado + """ + # Validaciones defensivas + duration_s = max(0.1, float(duration_s)) + logger.debug("Rendering STUTTER FX: source=%s, duration=%.1fs", Path(source_path).name, duration_s) + + audio, _ = self._load_audio(source_path) + + # VALIDACION TEMPRANA: Asegurar que el audio cargado es valido + if audio is None or audio.size == 0: + logger.warning("STUTTER: source audio is empty or invalid, returning silence") + return np.zeros((int(duration_s * self.sample_rate), 2), dtype=np.float32) + + # Asegurar 2D + audio = _ensure_2d_float(audio) + + # Validar que hay suficiente audio para procesar + min_required_samples = max(self._MIN_SAMPLES_FOR_EFFECT, 512) + if audio.shape[0] < min_required_samples: + logger.warning("STUTTER: source audio too short (%d samples, min %d), padding", audio.shape[0], min_required_samples) + padding = np.zeros((min_required_samples - audio.shape[0], audio.shape[1]), dtype=np.float32) + audio = np.concatenate([audio, padding], axis=0) + + source = self._find_hot_slice(audio, 0.20) # Ligeramente mas largo para mas contenido + + # VALIDACION: Asegurar que source es valido + if source is None or source.size == 0: + logger.warning("STUTTER: hot slice returned empty, returning silence") + return np.zeros((int(duration_s * self.sample_rate), 2), dtype=np.float32) + + # Asegurar 2D y validar channels + source = _ensure_2d_float(source) + if source.shape[0] < self._MIN_SAMPLES_FOR_SLICE: + logger.warning("STUTTER: hot slice too short (%d samples), padding to minimum", source.shape[0]) + padding = np.zeros((self._MIN_SAMPLES_FOR_SLICE - source.shape[0], source.shape[1]), dtype=np.float32) + source = np.concatenate([source, padding], axis=0) + + output_len = int(round(duration_s * self.sample_rate)) + + # Asegurar que output_len sea valido + output_len = max(1, output_len) + + output = np.zeros((output_len, source.shape[1]), dtype=np.float32) + + # Numero dinamico de slices segun duracion (mas cortos = menos slices) + # 5 slices para <2s, hasta 9 slices para >4s + num_slices = int(5 + min(4, int(duration_s / 1.0))) + num_slices = max(5, min(9, num_slices)) + + # Generar posiciones base con curva exponencial (mas denso hacia el final) + # Esto crea un patron mas musical tipo "building up" + base_positions = [] + for i in range(num_slices): + # Curva exponencial: 0 -> 0.85 con densidad creciente + t = i / max(1, num_slices - 1) + # Funcion exponencial para agrupar mas hacia el final + pos = (t ** 1.6) * 0.85 + base_positions.append(pos) + + # Aplicar variacion aleatoria a las posiciones para sonido mas organico + # Usar hash del source_path como semilla para consistencia + seed_hash = int(hashlib.md5(source_path.encode()).hexdigest()[:8], 16) % 10000 + np.random.seed(seed_hash) + + positions = [] + for i, base_pos in enumerate(base_positions): + # Variacion de +/- 3% en posicion + variation = (np.random.random() - 0.5) * 0.06 + pos = (base_pos + variation) * duration_s + # Asegurar que no se solapen demasiado + if i > 0: + pos = max(pos, positions[-1] + 0.08) + positions.append(min(pos, duration_s - 0.1)) + + logger.debug("STUTTER: placing %d slices at positions: %s", num_slices, [round(p, 3) for p in positions]) + + # Duracion base del slice con variacion + base_slice_duration = 0.16 + + # Crear buffer de reverb para los gaps (cola de reverb corta) + reverb_tail_samples = int(0.08 * self.sample_rate) # 80ms de reverb tail + + for index, position in enumerate(positions): + start = int(round(float(position) * self.sample_rate)) + + # Clamp start to valid range + start = max(0, min(start, output_len - 1)) + + # Variar duracion del gate: mas corto hacia el final con variacion aleatoria + gate_variation = (np.random.random() - 0.5) * 0.04 # +/- 20ms + gate_duration = base_slice_duration - (index * 0.012) + gate_variation + # Usar constante minima para slice de stutter + min_gate_duration = self._MIN_SAMPLES_FOR_SLICE / self.sample_rate + gate_duration = max(min_gate_duration, gate_duration) + if gate_duration == min_gate_duration: + logger.debug("Using minimum slice duration %.3fs for short audio", min_gate_duration) + gate_len = max(self._MIN_SAMPLES_FOR_SLICE, min(source.shape[0], int(round(gate_duration * self.sample_rate)))) + + # Extraer slice con copia defensiva + # Asegurar que gate_len no excede source + actual_gate_len = min(gate_len, source.shape[0]) + if actual_gate_len < self._MIN_SAMPLES_FOR_SLICE: + logger.debug("STUTTER: slice %d gate too short (%d samples), skipping", index, actual_gate_len) + continue + + slice_audio = np.array(source[:actual_gate_len], dtype=np.float32, copy=True) + slice_audio = _ensure_2d_float(slice_audio) + + # VALIDACION TEMPRANA: Verificar que el slice tiene contenido real + # _ensure_2d_float retorna (1,1) con zeros si esta vacio, verificamos shape + if slice_audio.shape[0] <= 1: + logger.debug("STUTTER: slice %d has invalid shape after ensure_2d_float %s, skipping", index, slice_audio.shape) + continue + + # Pitch shift mas extremo hacia el final (hasta 1 semitono = 1.0595) + # Aplicar desde el slice 3 en adelante + if index >= 3: + # Calcular pitch factor: va de 1.02 hasta ~1.06 (1 semitono) + pitch_progress = (index - 3) / max(1, num_slices - 4) + # Factor de pitch: 1.02 hasta 1.06 (casi 1 semitono) + pitch_factor = 1.02 + (pitch_progress * 0.04) + # Anadir pequena variacion aleatoria al pitch (+/- 10 cents) + pitch_variation = 1.0 + (np.random.random() - 0.5) * 0.012 + pitch_factor *= pitch_variation + + if scipy_signal is not None: + try: + pitched_len = max(1, int(len(slice_audio) / pitch_factor)) + pitched = np.zeros((pitched_len, slice_audio.shape[1]), dtype=np.float32) + for ch in range(slice_audio.shape[1]): + pitched[:, ch] = scipy_signal.resample(slice_audio[:, ch], pitched_len).astype(np.float32) + slice_audio = pitched + logger.debug("STUTTER: slice %d pitch shifted by factor %.3f", index, pitch_factor) + except Exception: + pass # Mantener slice original si falla + + # VALIDACION: Verificar que pitch shift no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after pitch shift, skipping", index) + continue + + # Aplicar ventana con fade mas corto (~0.5ms = 22 samples a 44.1kHz) + fade_samples = 22 # Reducido de 44 para transiciones mas rapidas + slice_audio = self._apply_slice_window(slice_audio, fade_samples=fade_samples) + + # VALIDACION: Verificar que window no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after window, skipping", index) + continue + + # Aplicar pequeno reverb al slice para espacialidad + # Wet mix bajo para no perder definicion + slice_audio = self._apply_short_reverb(slice_audio, decay=0.25, delay_ms=35.0) + + # VALIDACION: Verificar que reverb no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after reverb, skipping", index) + continue + + end = min(output_len, start + slice_audio.shape[0]) + if end <= start: + logger.debug("STUTTER: slice %d has invalid range (start=%d, end=%d), skipping", index, start, end) + continue + + # Ajustar slice al espacio disponible + actual_len = end - start + + # VALIDACION CRITICA: Asegurar que actual_len sea al menos 1 + if actual_len <= 0: + logger.debug("STUTTER: slice %d has actual_len=%d, skipping", index, actual_len) + continue + + # Trim solo si hay suficiente contenido despues del trim + if actual_len < slice_audio.shape[0]: + # Asegurar que el trim no produzca array vacio + if actual_len >= 1: + slice_audio = slice_audio[:actual_len] + else: + logger.debug("STUTTER: slice %d would become empty after trim (actual_len=%d), skipping", index, actual_len) + continue + + # VALIDACION FINAL: Verificar que slice_audio tiene contenido antes de mezclar + if slice_audio.size == 0 or slice_audio.shape[0] == 0: + logger.debug("STUTTER: slice %d is empty before mix, skipping", index) + continue + + # VALIDACION CRITICA DE SHAPES: Asegurar compatibilidad antes de mezclar + target_shape = output[start:end].shape + if slice_audio.shape != target_shape: + logger.debug("STUTTER: slice %d shape mismatch - slice: %s, target: %s", + index, slice_audio.shape, target_shape) + # Intentar ajustar slice al target shape + if slice_audio.shape[0] != target_shape[0]: + # Stretch o trim slice para que coincida + slice_audio = self._stretch_to_length(slice_audio, target_shape[0]) + if slice_audio.shape[1] != target_shape[1]: + # Ajustar canales + if slice_audio.shape[1] == 1 and target_shape[1] == 2: + slice_audio = np.repeat(slice_audio, 2, axis=1) + elif slice_audio.shape[1] == 2 and target_shape[1] == 1: + slice_audio = slice_audio[:, :1] + else: + logger.debug("STUTTER: slice %d incompatible channels, skipping", index) + continue + + # Ganancia variable por posicion con variacion aleatoria + # Mas alto hacia el final con pequenas variaciones + gain_base = 0.50 + (index * 0.07) + gain_variation = (np.random.random() - 0.5) * 0.08 # +/- 4% + gain = gain_base + gain_variation + gain = max(0.3, min(0.95, gain)) # Clamp entre 0.3 y 0.95 + + # Validate shapes before mixing (doble validacion defensiva) + valid, msg = _validate_mix_shapes(output[start:end], slice_audio) + if not valid: + logger.debug("STUTTER: skipping slice %d at %d: %s", index, start, msg) + continue + + output[start:end] += slice_audio * gain + + # Agregar reverb "ghost" en el gap despues del slice (solo si no es el ultimo) + if index < len(positions) - 1: + gap_start = end + gap_end = min(output_len, gap_start + reverb_tail_samples) + if gap_end > gap_start: + # Crear ghost reverb tail muy sutil del slice anterior + ghost_len = gap_end - gap_start + + # VALIDACION: Asegurar que ghost_len es valido + if ghost_len <= 0: + logger.debug("STUTTER: slice %d has invalid ghost_len=%d, skipping ghost", index, ghost_len) + else: + ghost_audio = np.zeros((ghost_len, source.shape[1]), dtype=np.float32) + + # Copiar la cola del slice con decaimiento exponencial + # VALIDACION: Asegurar que tail_source tiene contenido + tail_samples = min(len(slice_audio), ghost_len * 2) + if tail_samples > 0: + tail_source = slice_audio[-tail_samples:] + if tail_source.size > 0: + decay_len = min(len(tail_source), ghost_len) + # VALIDACION: Asegurar que decay_len es valido + if decay_len > 0: + decay_curve = np.exp(-4.0 * np.linspace(0, 1, decay_len)).reshape(-1, 1).astype(np.float32) + # VALIDACION: El slicing defensivo asegura que tail_source[-decay_len:] tiene contenido + if tail_source[-decay_len:].size > 0: + ghost_audio[:decay_len] = tail_source[-decay_len:] * decay_curve * 0.15 + output[gap_start:gap_start + ghost_len] += ghost_audio + else: + logger.debug("STUTTER: slice %d tail_source slice is empty, skipping ghost", index) + else: + logger.debug("STUTTER: slice %d has invalid decay_len=%d, skipping ghost", index, decay_len) + else: + logger.debug("STUTTER: slice %d tail_source is empty, skipping ghost", index) + else: + logger.debug("STUTTER: slice %d has invalid tail_samples=%d, skipping ghost", index, tail_samples) + + # Fade global mas suave + output = self._apply_fade(output, fade_in_s=0.003, fade_out_s=0.15) + result = self._normalize(output) # Usa valor unificado por defecto + + # Fallback for empty render results + if result is None or result.size == 0: + logger.warning("STUTTER: fallback to silence (empty render result)") + result = np.zeros((int(2.5 * self.sample_rate), 2), dtype=np.float32) + + final_duration = len(result) / self.sample_rate + logger.debug("STUTTER: generated %s (duration=%.1fs, slices=%d)", Path(source_path).name, final_duration, num_slices) + return result + + + def _output_path(self, source_path: str, variant_seed: int, suffix: str) -> Path: + """Genera ruta de salida unica para un archivo procesado.""" + source = Path(source_path) + digest = hashlib.sha1(f"{source.resolve()}::{variant_seed}::{suffix}".encode("utf-8")).hexdigest()[:10] + return self.output_dir / f"{source.stem}_{suffix}_{digest}.wav" + + def _analyze_source_quality(self, audio: np.ndarray, sample_rate: int, fx_type: str) -> Dict[str, Any]: + """Analyzes source audio quality for FX derivation. + + Returns quality metrics for source selection decisions. + + Args: + audio: Audio array (samples, channels) + sample_rate: Sample rate in Hz + fx_type: Type of FX to derive ('reverse', 'riser', 'downlifter', 'stutter') + + Returns: + Dict with quality metrics: spectral_content, dynamic_range, suitability_score + """ + if audio is None or audio.size == 0: + return {"spectral_content": 0.0, "dynamic_range": 0.0, "suitability_score": 0.0, "recommended": False} + + audio = self._validate_audio_array(audio, context="_analyze_source_quality") + mono = np.mean(np.abs(audio), axis=1) if audio.ndim > 1 else np.abs(audio) + + rms = float(np.sqrt(np.mean(mono ** 2))) if mono.size > 0 else 0.0 + peak = float(np.max(mono)) if mono.size > 0 else 0.0 + dynamic_range = peak / max(rms, 1e-10) + + spectral_content = 0.5 + # Asegurar minimo de samples para FFT adaptativo + min_fft_samples = 512 + if scipy_signal is not None and mono.size >= min_fft_samples: + try: + # Adaptar n_fft al tamaño del audio para evitar warnings + n_fft = min(2048, max(min_fft_samples, len(mono) // 2)) + fft_len = min(n_fft, len(mono)) + freqs = np.fft.rfft(mono[:fft_len]) + freq_magnitude = np.abs(freqs) + if freq_magnitude.size > 10: + low_energy = np.sum(freq_magnitude[:max(1, len(freq_magnitude)//8)]) + mid_energy = np.sum(freq_magnitude[max(1, len(freq_magnitude)//8):len(freq_magnitude)//2]) + high_energy = np.sum(freq_magnitude[len(freq_magnitude)//2:]) + total = low_energy + mid_energy + high_energy + 1e-10 + high_ratio = high_energy / total + mid_ratio = mid_energy / total + spectral_content = float(0.3 + 0.5 * (high_ratio + mid_ratio * 0.5)) + except Exception: + pass + + suitability_scores = { + "reverse": min(1.0, spectral_content * 0.7 + min(1.0, dynamic_range) * 0.3), + "riser": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), + "downlifter": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), + "stutter": min(1.0, 0.3 + spectral_content * 0.4 + min(1.0, dynamic_range) * 0.3), + } + + score = suitability_scores.get(fx_type, 0.5) + recommended = score >= 0.4 and dynamic_range >= 2.0 and rms >= 0.01 + + return { + "spectral_content": round(spectral_content, 3), + "dynamic_range": round(dynamic_range, 3), + "rms": round(rms, 4), + "suitability_score": round(score, 3), + "recommended": recommended, + } + + def _build_positions(self, sections: List[Dict[str, Any]], bpm: float = 128.0) -> Dict[str, List[float]]: + """Construye posiciones de FX basandose en la estructura de secciones. + + Phase 2 Improvements: + - BPM-aware timing for musical placement + - Precise reverse placement exactly at section boundaries + - Riser ends precisely before drops for maximum impact + - Downlifter placed after drops for clean section exits + - Professional stutter placement at build peaks and drop tails + - Enhanced section type detection (intro, breakdown, peak, etc.) + - Duplicate suppression with minimum spacing + - Quality-aware source selection + + Args: + sections: Lista de secciones con kind, name, beats + bpm: BPM del proyecto para timing musical + + Returns: + Diccionario con listas de posiciones por tipo de FX + """ + reverse_positions: List[float] = [] + riser_positions: List[float] = [] + downlifter_positions: List[float] = [] + stutter_positions: List[float] = [] + + offsets = _section_offsets(sections) + beat_duration = 60.0 / max(60.0, min(200.0, bpm)) + bar_duration = beat_duration * 4.0 + + def _add_unique(positions: List[float], value: float, min_spacing: float = 2.0) -> None: + if not any(abs(p - value) < min_spacing for p in positions): + positions.append(round(max(0.0, value), 3)) + + def _section_type(section: Dict[str, Any]) -> str: + kind = str(section.get("kind", "")).lower() + name = str(section.get("name", "")).lower() + if "intro" in kind or "intro" in name: + return "intro" + if "break" in kind or "break" in name or "breakdown" in name: + return "break" + if "build" in kind or "build" in name: + return "build" + if "drop" in kind or "drop" in name: + return "drop" + if "peak" in name or "main" in name: + return "peak" + if "outro" in kind or "outro" in name: + return "outro" + if "groove" in name: + return "groove" + return kind or "unknown" + + for index, (section, start, end) in enumerate(offsets): + section_type = _section_type(section) + name = str(section.get("name", "")).lower() + span = max(1.0, end - start) + is_peak = "peak" in name or "drop b" in name or "main" in name or "peak" in section_type + is_build = section_type == "build" + is_break = section_type == "break" + is_drop = section_type == "drop" + is_outro = section_type == "outro" + is_intro = section_type == "intro" + + reverse_bar_offset = bar_duration * 1.5 + if index > 0 and is_drop: + reverse_offset = min(8.0, max(4.0, reverse_bar_offset)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=3.0) + elif index > 0 and is_break: + reverse_offset = min(6.0, max(3.0, reverse_bar_offset * 0.8)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.5) + elif index > 0 and is_build: + if index > 1: + reverse_offset = min(7.0, max(3.0, reverse_bar_offset)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.0) + + if is_build: + riser_duration = min(12.0, max(4.0, span * 0.7)) + beat_duration_seconds = beat_duration + riser_quantized = (riser_duration / beat_duration_seconds) * beat_duration_seconds + riser_quantized = max(4.0, min(12.0, riser_quantized)) + riser_start = max(start, end - riser_quantized) + _add_unique(riser_positions, riser_start, min_spacing=4.0) + + stutter_offset = bar_duration * 0.5 + stutter_start = max(start, end - stutter_offset - 0.5) + _add_unique(stutter_positions, stutter_start, min_spacing=1.5) + + if is_break and not is_peak: + downlifter_offset = bar_duration * 0.25 + _add_unique(downlifter_positions, start + downlifter_offset, min_spacing=3.0) + + elif is_drop and not is_peak: + down_offset = bar_duration * 0.3 + _add_unique(downlifter_positions, start + down_offset, min_spacing=3.0) + + if is_outro: + if span > bar_duration * 2: + _add_unique(downlifter_positions, start + bar_duration, min_spacing=3.0) + outro_down_position = start + span * 0.45 + _add_unique(downlifter_positions, outro_down_position, min_spacing=2.5) + + if is_peak and span > bar_duration: + stutter_offset = min(bar_duration * 1.5, span * 0.25) + _add_unique(stutter_positions, end - stutter_offset, min_spacing=1.5) + + if span > bar_duration * 3: + peak_stutter_position = start + span * 0.55 + _add_unique(stutter_positions, peak_stutter_position, min_spacing=bar_duration) + + if is_intro and span > bar_duration * 2: + intro_reverse_offset = bar_duration * 0.75 + _add_unique(reverse_positions, start + intro_reverse_offset, min_spacing=2.5) + + return { + "reverse": sorted(set(reverse_positions)), + "riser": sorted(set(riser_positions)), + "downlifter": sorted(set(downlifter_positions)), + "stutter": sorted(set(stutter_positions)), + } + + def build_transition_layers( + self, + reference_audio_plan: Dict[str, Any], + sections: List[Dict[str, Any]], + project_bpm: float, + variant_seed: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Construye capas de transicion desde un plan de audio de referencia. + + Args: + reference_audio_plan: Plan con matches de audio + sections: Lista de secciones del proyecto + project_bpm: BPM del proyecto + variant_seed: Semilla para variacion + + Returns: + Lista de diccionarios con info de capas generadas + """ + logger.debug("build_transition_layers called: bpm=%.1f, variant_seed=%s", project_bpm, variant_seed) + + if not isinstance(reference_audio_plan, dict): + logger.debug("reference_audio_plan is not a dict, returning empty layers") + return [] + + selected = reference_audio_plan.get("matches", {}) or {} + if not isinstance(selected, dict): + logger.debug("matches is not a dict, returning empty layers") + return [] + + # Validar project_bpm + project_bpm = max(20.0, min(300.0, float(project_bpm or 120.0))) + + variant_seed = int(variant_seed or 0) + positions = self._build_positions(sections, bpm=project_bpm) + logger.debug("Calculated FX positions: reverse=%s, riser=%s, downlifter=%s, stutter=%s", + positions["reverse"], positions["riser"], positions["downlifter"], positions["stutter"]) + layers: List[Dict[str, Any]] = [] + + FX_SOURCE_PRIORITIES = { + "reverse": [ + ("crash_fx", 0.9), + ("fill_fx", 0.85), + ("atmos_fx", 0.75), + ("synth_loop", 0.65), + ("vocal_shot", 0.55), + ], + "riser": [ + ("synth_loop", 0.9), + ("vocal_loop", 0.85), + ("atmos_fx", 0.8), + ("pad", 0.6), + ], + "downlifter": [ + ("crash_fx", 0.9), + ("atmos_fx", 0.85), + ("synth_loop", 0.7), + ("fill_fx", 0.65), + ], + "stutter": [ + ("vocal_shot", 0.95), + ("vocal_loop", 0.85), + ("snare_roll", 0.8), + ("synth_peak", 0.65), + ], + } + + FX_FALLBACK_QUERIES = { + "reverse": ["crash", "cymbal", "impact"], + "riser": ["riser", "buildup", "sweep"], + "downlifter": ["atmos", "drone", "texture"], + "stutter": ["vocal", "synth", "chord", "fx"], + } + + def _find_fallback_source(fx_type: str) -> str: + """Find source directly from SampleManager when selected is empty.""" + try: + import importlib.util + PACKAGE_DIR = Path(__file__).resolve().parent.parent + sample_manager_path = PACKAGE_DIR / "MCP_Server" / "sample_manager.py" + if sample_manager_path.exists(): + spec = importlib.util.spec_from_file_location("sample_manager", sample_manager_path) + sm_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sm_mod) + manager = sm_mod.get_manager() + else: + from .sample_manager import get_manager + manager = get_manager() + if manager is None: + return "" + queries = FX_FALLBACK_QUERIES.get(fx_type, []) + for query in queries: + samples = manager.search(query=query, limit=5) + for sample in samples: + path = str(sample.path) + if Path(path).exists(): + try: + audio, sr = self._load_audio(path) + if audio is not None and audio.shape[0] > 1000: + logger.debug("Fallback source %s found for %s FX", Path(path).name, fx_type) + return path + except Exception: + continue + except Exception as e: + logger.debug("Fallback search failed for %s: %s", fx_type, e) + return "" + + def find_best_source(fx_type: str) -> str: + """Find best source for FX type based on quality and priority.""" + priorities = FX_SOURCE_PRIORITIES.get(fx_type, []) + for key, base_score in priorities: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + try: + audio, sr = self._load_audio(path) + quality = self._analyze_source_quality(audio, sr, fx_type) + if quality.get("recommended", False): + adjusted_score = base_score * quality.get("suitability_score", 0.5) + if adjusted_score >= 0.35: + logger.debug("Source %s selected for %s FX: quality=%.2f, score=%.2f", + Path(path).name, fx_type, quality.get("suitability_score", 0), adjusted_score) + return path + logger.debug("Source %s rejected for %s FX: quality=%.2f, recommended=%s", + Path(path).name, fx_type, quality.get("suitability_score", 0), quality.get("recommended")) + except Exception as e: + logger.debug("Could not analyze source %s for %s: %s", path, fx_type, e) + for key, _ in priorities: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + return path + fallback = _find_fallback_source(fx_type) + if fallback: + logger.info("Using fallback source for %s FX: %s", fx_type, Path(fallback).name) + return fallback + + def source_path(*keys: str) -> str: + for key in keys: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + return path + return "" + + def maybe_add(name: str, path: str, output_suffix: str, color: int, volume: float, beat_positions: List[float], renderer): + if not path or not beat_positions: + logger.debug("Skipping %s: path=%s, positions=%s", name, path if path else "(empty)", beat_positions if beat_positions else "(empty)") + return + try: + logger.debug("Generating %s from %s, duration=%.1fs, positions=%s", + name, Path(path).name, 4.0 if "REVERSE" in name else (8.0 if "RISER" in name else (6.0 if "DOWNLIFTER" in name else 2.5)), beat_positions) + rendered = renderer(path) + output_path = self._output_path(path, variant_seed, output_suffix) + file_path = self._write_audio(output_path, rendered, self.sample_rate) + logger.debug("Successfully generated %s -> %s", name, Path(file_path).name) + except Exception as exc: + logger.warning("No se pudo generar %s desde %s: %s", name, Path(path).name, exc) + logger.debug("Error details for %s: type=%s, message=%s", name, type(exc).__name__, exc) + return + layers.append({ + "name": name, + "file_path": file_path, + "positions": beat_positions, + "color": color, + "volume": volume, + "source": Path(path).name, + "generated": True, + }) + + reverse_source = find_best_source("reverse") + if reverse_source and positions["reverse"]: + maybe_add( + "AUDIO RESAMPLE REVERSE FX", + reverse_source, + "reverse_fx", + 26, + 0.58, + positions["reverse"], + lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), + ) + else: + fallback_reverse = source_path("crash_fx", "fill_fx", "atmos_fx", "synth_loop", "vocal_shot") + if fallback_reverse and positions["reverse"]: + maybe_add( + "AUDIO RESAMPLE REVERSE FX", + fallback_reverse, + "reverse_fx", + 26, + 0.58, + positions["reverse"], + lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), + ) + + riser_source = find_best_source("riser") + if riser_source and positions["riser"]: + maybe_add( + "AUDIO RESAMPLE RISER", + riser_source, + "riser_fx", + 27, + 0.54, + positions["riser"], + lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), + ) + else: + fallback_riser = source_path("synth_loop", "vocal_loop", "atmos_fx", "pad") + if fallback_riser and positions["riser"]: + maybe_add( + "AUDIO RESAMPLE RISER", + fallback_riser, + "riser_fx", + 27, + 0.54, + positions["riser"], + lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), + ) + + downlifter_source = find_best_source("downlifter") + if downlifter_source and positions["downlifter"]: + maybe_add( + "AUDIO RESAMPLE DOWNLIFTER", + downlifter_source, + "downlifter_fx", + 54, + 0.50, + positions["downlifter"], + lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), + ) + else: + fallback_downlifter = source_path("crash_fx", "atmos_fx", "synth_loop", "fill_fx") + if fallback_downlifter and positions["downlifter"]: + maybe_add( + "AUDIO RESAMPLE DOWNLIFTER", + fallback_downlifter, + "downlifter_fx", + 54, + 0.50, + positions["downlifter"], + lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), + ) + + stutter_source = find_best_source("stutter") + if stutter_source and positions["stutter"]: + try: + source_audio, _ = self._load_audio(stutter_source) + min_samples = 1000 + if source_audio.shape[0] < min_samples: + logger.warning("Skipping STUTTER layer: source audio too short (%d samples, min %d)", + source_audio.shape[0], min_samples) + else: + quality = self._analyze_source_quality(source_audio, self.sample_rate, "stutter") + if quality.get("suitability_score", 0) >= 0.25: + maybe_add( + "AUDIO RESAMPLE STUTTER", + stutter_source, + "stutter_fx", + 41, + 0.56, + positions["stutter"], + lambda path: self._render_stutter(path, duration_s=2.5), + ) + else: + logger.debug("STUTTER source quality too low: %.2f", quality.get("suitability_score", 0)) + except Exception as exc: + logger.warning("Skipping STUTTER layer: failed to validate source: %s", exc) + else: + fallback_stutter = source_path("vocal_shot", "vocal_loop", "snare_roll", "synth_peak") + if fallback_stutter and positions["stutter"]: + try: + source_audio, _ = self._load_audio(fallback_stutter) + min_samples = 1000 + if source_audio.shape[0] >= min_samples: + maybe_add( + "AUDIO RESAMPLE STUTTER", + fallback_stutter, + "stutter_fx", + 41, + 0.56, + positions["stutter"], + lambda path: self._render_stutter(path, duration_s=2.5), + ) + except Exception as exc: + logger.warning("Fallback STUTTER also failed: %s", exc) + + logger.info("Created %d derived layers: %s", len(layers), [layer['name'] for layer in layers]) + return layers + + def invalidate_stale_cache(self) -> int: + """Elimina entradas de cache cuyos archivos han sido modificados. + + Este metodo verifica cada entrada en el cache y elimina aquellas + donde el archivo tiene un mtime diferente al que esta en la key. + + Nota: Con el diseno actual donde mtime es parte de la key, las + entradas stale naturalmente expiran por LRU. Este metodo es + utilitario para limpieza proactiva. + + Returns: + Numero de entradas eliminadas + """ + removed = 0 + keys_to_remove: List[str] = [] + + for key in list(self._audio_cache.keys()): + # Extraer path de la key (formato: "path::mtime_ns" o solo "path") + if "::" in key: + path_str, _ = key.rsplit("::", 1) + else: + path_str = key + + path = Path(path_str) + + # Verificar si el archivo aun existe y tiene el mismo mtime + if not path.exists(): + # Archivo eliminado, marcar para remover + keys_to_remove.append(key) + removed += 1 + continue + + try: + current_mtime_ns = path.stat().st_mtime_ns + # Reconstruir la key esperada con el mtime actual + expected_key = self._get_cache_key(path_str, current_mtime_ns) + + # Si la key actual no coincide con la esperada, el archivo cambio + if key != expected_key: + keys_to_remove.append(key) + removed += 1 + except OSError: + # Error al acceder al archivo, marcar para remover + keys_to_remove.append(key) + removed += 1 + + # Remover las entradas stale + for key in keys_to_remove: + del self._audio_cache[key] + + if removed > 0: + logger.debug("Invalidadas %d entradas de cache stale", removed) + + return removed + + def clear_cache(self) -> int: + """Limpia el cache de audio y devuelve el numero de entradas eliminadas. + + Returns: + Numero de entradas que fueron eliminadas del cache + """ + count = len(self._audio_cache) + self._audio_cache.clear() + self._cache_sizes.clear() + self._cache_total_bytes = 0 + self._cache_hits = 0 + self._cache_misses = 0 + return count + + def cache_size(self) -> int: + """Devuelve el numero de archivos en cache. + + Returns: + Numero de entradas en cache + """ + return len(self._audio_cache) + + def cache_stats(self) -> Dict[str, Any]: + """Devuelve estadisticas del cache de audio. + + Phase 1 Improvement: Metodo nuevo para monitorear rendimiento del cache. + + Returns: + Diccionario con estadisticas: entries, bytes, hits, misses, hit_rate + """ + total_requests = self._cache_hits + self._cache_misses + hit_rate = self._cache_hits / total_requests if total_requests > 0 else 0.0 + + return { + "entries": len(self._audio_cache), + "max_entries": self._CACHE_LIMIT, + "bytes": self._cache_total_bytes, + "max_bytes": self._CACHE_MAX_SIZE_BYTES, + "mb": round(self._cache_total_bytes / (1024 * 1024), 2), + "hits": self._cache_hits, + "misses": self._cache_misses, + "hit_rate": round(hit_rate, 3), + "max_age_s": self._CACHE_MAX_AGE_S, + } diff --git a/AbletonMCP_AI/MCP_Server/diversity_memory.py b/AbletonMCP_AI/MCP_Server/diversity_memory.py new file mode 100644 index 0000000..7b4212e --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/diversity_memory.py @@ -0,0 +1,381 @@ +""" +diversity_memory.py - Sistema de memoria de diversidad entre generaciones + +Persistencia cross-generation para evitar repetición de familias de samples. +Incluye TTL automático, penalización acumulativa y thread-safety. +""" + +import json +import logging +import os +import threading +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Any +from datetime import datetime + +logger = logging.getLogger("DiversityMemory") + +# ============================================================================= +# CONFIGURACIÓN +# ============================================================================= + +DIVERSITY_MEMORY_FILE = "diversity_memory.json" +MAX_GENERATIONS_TTL = 10 # Familias expiran después de 10 generaciones +CRITICAL_ROLES = {'kick', 'clap', 'hat', 'hat_closed', 'hat_open', 'bass_loop', 'vocal_loop', 'top_loop'} + +# Fórmula de penalización acumulativa +# 0 usos → 1.0 (sin penalización) +# 1 uso → 0.7 (penalización leve) +# 2 usos → 0.5 (penalización media) +# 3+ usos → 0.3 (penalización fuerte) +PENALTY_FORMULA = {0: 1.0, 1: 0.7, 2: 0.5, 3: 0.3} +MAX_PENALTY = 0.3 + +# Keywords para detección de familias +FAMILY_KEYWORDS = { + # Drums por tipo de máquina + '808': ['808', 'tr808', 'tr-808', 'eight-oh-eight'], + '909': ['909', 'tr909', 'tr-909', 'nine-oh-nine'], + '707': ['707', 'tr707'], + '606': ['606', 'tr606'], + 'acoustic': ['acoustic', 'real', 'live', 'studio', 'analog_real'], + 'vinyl': ['vinyl', 'vin', 'recorded', 'sampled_drum'], + 'digital': ['digital', 'digi', 'synthetic', 'synth', 'electronic'], + 'analog': ['analog', 'analogue', 'moog', 'oberheim', 'sequential'], + # Bass por tipo + 'reese': ['reese', 'reese_bass'], + 'acid': ['acid', '303', 'tb303', 'bassline'], + 'sub': ['sub', 'subby', 'sub_bass'], + 'growl': ['growl', 'wobble', 'dubstep'], + # Vocals por estilo + 'vocal_chop': ['chop', 'chopped', 'stutter'], + 'vocal_phrase': ['phrase', 'hook', 'shout'], + 'vocal_verse': ['verse', 'acapella', 'acappella'], + # Loops por textura + 'percu_shaker': ['shaker', 'shake'], + 'percu_conga': ['conga', 'bongo', 'latin'], + 'percu_tribal': ['tribal', 'ethnic', 'world'], +} + +# ============================================================================= +# ESTRUCTURA DE DATOS +# ============================================================================= + +class DiversityMemory: + """Memoria thread-safe de diversidad con persistencia JSON.""" + + def __init__(self, project_dir: Optional[Path] = None): + """ + Inicializa la memoria de diversidad. + + Args: + project_dir: Directorio del proyecto para guardar el archivo JSON + """ + self._lock = threading.RLock() + + # Determinar directorio del proyecto + if project_dir is None: + # Buscar en directorios conocidos + possible_dirs = [ + Path(__file__).parent.parent, # MCP_Server/../ + Path.home() / "Documents" / "AbletonMCP_AI", + Path(os.getcwd()), + ] + for pd in possible_dirs: + if pd.exists() and pd.is_dir(): + project_dir = pd + break + + self._file_path = (project_dir / DIVERSITY_MEMORY_FILE) if project_dir else Path(DIVERSITY_MEMORY_FILE) + + # Datos en memoria + self._used_families: Dict[str, int] = defaultdict(int) + self._used_paths: Dict[str, int] = defaultdict(int) + self._generation_count: int = 0 + self._last_updated: str = datetime.now().isoformat() + + # Cargar datos existentes + self._load() + + def _load(self) -> None: + """Carga la memoria desde el archivo JSON.""" + if self._file_path.exists(): + try: + with open(self._file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + self._used_families = defaultdict(int, data.get('used_families', {})) + self._used_paths = defaultdict(int, data.get('used_paths', {})) + self._generation_count = data.get('generation_count', 0) + self._last_updated = data.get('last_updated', datetime.now().isoformat()) + + logger.debug(f"DiversityMemory cargada desde {self._file_path}") + logger.debug(f" - Familias usadas: {len(self._used_families)}") + logger.debug(f" - Paths usados: {len(self._used_paths)}") + logger.debug(f" - Generación #{self._generation_count}") + except Exception as e: + logger.warning(f"Error cargando diversity_memory.json: {e}") + # Resetear a valores por defecto + self._reset_data() + else: + logger.debug(f"Archivo {self._file_path} no existe, iniciando memoria vacía") + + def _save(self) -> None: + """Guarda la memoria al archivo JSON.""" + with self._lock: + data = { + 'used_families': dict(self._used_families), + 'used_paths': dict(self._used_paths), + 'generation_count': self._generation_count, + 'last_updated': datetime.now().isoformat(), + 'version': '1.0' + } + + try: + # Crear directorio si no existe + self._file_path.parent.mkdir(parents=True, exist_ok=True) + + with open(self._file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + logger.debug(f"DiversityMemory guardada en {self._file_path}") + except Exception as e: + logger.error(f"Error guardando diversity_memory.json: {e}") + + def _reset_data(self) -> None: + """Resetea los datos a valores iniciales.""" + self._used_families.clear() + self._used_paths.clear() + self._generation_count = 0 + self._last_updated = datetime.now().isoformat() + + def record_sample_usage(self, role: str, sample_path: str, sample_name: str) -> None: + """ + Registra el uso de un sample en esta generación. + + Args: + role: Rol del sample (ej: 'kick', 'clap') + sample_path: Path completo al archivo + sample_name: Nombre del archivo + """ + if role not in CRITICAL_ROLES: + return # Solo tracking de roles críticos + + with self._lock: + family = self._detect_family(sample_path, sample_name) + + if family: + self._used_families[family] += 1 + logger.debug(f"Registrada familia '{family}' para rol '{role}' (usos: {self._used_families[family]})") + + # Siempre registrar el path + self._used_paths[sample_path] += 1 + + def record_generation_complete(self) -> None: + """ + Marca el fin de una generación y aplica TTL. + Decrementa contadores y elimina familias expiradas. + """ + with self._lock: + self._generation_count += 1 + + # Aplicar TTL a familias + families_to_remove = [] + for family, count in self._used_families.items(): + if count > 0: + # TTL: después de MAX_GENERATIONS_TTL, eliminar familia + if count >= MAX_GENERATIONS_TTL: + families_to_remove.append(family) + # Penalización decreciente con el tiempo + # En cada generación sin uso, reduce el conteo + # (simula decaimiento) + + # Remover familias expiradas + for family in families_to_remove: + del self._used_families[family] + logger.debug(f"Familia '{family}' expirada después de {MAX_GENERATIONS_TTL} generaciones") + + # Guardar después de cada generación + self._save() + + logger.info(f"Generación #{self._generation_count} completada. " + f"Familias activas: {len(self._used_families)}") + + def get_penalty_for_sample(self, role: str, sample_path: str, sample_name: str) -> float: + """ + Calcula la penalización para un sample específico. + + Returns: + float entre 0.0 y 1.0 (multiplicar el score original por este factor) + 1.0 = sin penalización + 0.3 = penalización máxima + """ + if role not in CRITICAL_ROLES: + return 1.0 # Sin penalización para roles no críticos + + with self._lock: + family = self._detect_family(sample_path, sample_name) + family_uses = self._used_families.get(family, 0) if family else 0 + path_uses = self._used_paths.get(sample_path, 0) + + # Penalización por familia (acumulativa) + if family_uses >= 3: + family_penalty = MAX_PENALTY + elif family_uses > 0: + family_penalty = PENALTY_FORMULA.get(family_uses, MAX_PENALTY) + else: + family_penalty = 1.0 + + # Penalización adicional por path específico (evitar repetición exacta) + if path_uses >= 2: + path_penalty = 0.5 + elif path_uses == 1: + path_penalty = 0.8 + else: + path_penalty = 1.0 + + total_penalty = family_penalty * path_penalty + + if total_penalty < 1.0: + logger.debug(f"Penalización para '{sample_name}': {total_penalty:.2f} " + f"(familia: {family_penalty:.2f} [{family_uses} usos], " + f"path: {path_penalty:.2f} [{path_uses} usos])") + + return total_penalty + + def _detect_family(self, sample_path: str, sample_name: str) -> Optional[str]: + """ + Detecta la familia de un sample basado en path y nombre. + + Estrategias (en orden de prioridad): + 1. Keywords en el nombre del archivo + 2. Directorio padre + 3. Path completo + + Returns: + Nombre de la familia o None si no se detecta + """ + path_lower = sample_path.lower() + name_lower = sample_name.lower() + + # 1. Buscar keywords en nombre + for family, keywords in FAMILY_KEYWORDS.items(): + for kw in keywords: + if kw in name_lower: + return family + + # 2. Buscar en directorio padre + # Ej: "808_Kicks/kick_808_warm.wav" → familia "808" + parent_dir = Path(sample_path).parent.name.lower() if sample_path else "" + for family, keywords in FAMILY_KEYWORDS.items(): + for kw in keywords: + if kw in parent_dir: + return family + + # 3. Buscar en path completo + for family, keywords in FAMILY_KEYWORDS.items(): + for kw in keywords: + if kw in path_lower: + return family + + # Si no hay coincidencia, devolver None + return None + + def get_stats(self) -> Dict[str, Any]: + """ + Retorna estadísticas de la memoria de diversidad. + + Returns: + Dict con: + - used_families: dict de familias y conteos + - total_families: int + - used_paths: dict de paths y conteos + - total_paths: int + - generation_count: int + - file_location: str + """ + with self._lock: + return { + 'used_families': dict(self._used_families), + 'total_families': len(self._used_families), + 'used_paths': dict(self._used_paths), + 'total_paths': len(self._used_paths), + 'generation_count': self._generation_count, + 'critical_roles': list(CRITICAL_ROLES), + 'file_location': str(self._file_path.absolute()) if self._file_path.exists() else None, + 'max_generations_ttl': MAX_GENERATIONS_TTL, + 'penalty_formula': PENALTY_FORMULA, + } + + def reset(self) -> None: + """Limpia toda la memoria de diversidad.""" + with self._lock: + self._reset_data() + self._save() + logger.info("DiversityMemory reseteada completamente") + + +# ============================================================================= +# INSTANCIA GLOBAL +# ============================================================================= + +# Instancia singleton (thread-safe por el lock interno) +_diversity_memory: Optional[DiversityMemory] = None + + +def get_diversity_memory(project_dir: Optional[Path] = None) -> DiversityMemory: + """Obtiene la instancia global de DiversityMemory.""" + global _diversity_memory + if _diversity_memory is None: + _diversity_memory = DiversityMemory(project_dir) + return _diversity_memory + + +def reset_diversity_memory() -> None: + """API: Limpia la memoria de diversidad.""" + memory = get_diversity_memory() + memory.reset() + + +def get_diversity_memory_stats() -> Dict[str, Any]: + """API: Obtiene estadísticas de la memoria.""" + memory = get_diversity_memory() + return memory.get_stats() + + +def record_sample_usage(role: str, sample_path: str, sample_name: str) -> None: + """API: Registra uso de un sample.""" + memory = get_diversity_memory() + memory.record_sample_usage(role, sample_path, sample_name) + + +def record_generation_complete() -> None: + """API: Marca fin de generación y aplica TTL.""" + memory = get_diversity_memory() + memory.record_generation_complete() + + +def get_penalty_for_sample(role: str, sample_path: str, sample_name: str) -> float: + """API: Obtiene penalización para un sample.""" + memory = get_diversity_memory() + return memory.get_penalty_for_sample(role, sample_path, sample_name) + + +# ============================================================================= +# FUNCIÓN DE AYUDA PARA DETECCIÓN EXTERNA +# ============================================================================= + +def detect_sample_family(sample_path: str, sample_name: str) -> Optional[str]: + """ + Detecta la familia de un sample (función pública). + Usa la misma lógica que DiversityMemory. + """ + memory = get_diversity_memory() + return memory._detect_family(sample_path, sample_name) + + +# Familias conocidas para referencia +def get_known_families() -> Dict[str, List[str]]: + """Retorna las familias de samples conocidas con sus keywords.""" + return FAMILY_KEYWORDS.copy() diff --git a/AbletonMCP_AI/MCP_Server/enhanced_device_automation.py b/AbletonMCP_AI/MCP_Server/enhanced_device_automation.py new file mode 100644 index 0000000..213cb15 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/enhanced_device_automation.py @@ -0,0 +1,431 @@ +""" +Enhanced Device Automation for Timbral Movement Between Sections. +This module provides expanded device automation parameters for musical variation. +""" + +# ============================================================================= +# ENHANCED SECTION DEVICE AUTOMATION - More timbral color per section +# ============================================================================= + +# Automatizacion de devices en tracks individuales por rol - ENHANCED +SECTION_DEVICE_AUTOMATION = { + # BASS - Filtros, drive y compresion dinamica + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + }, + 'Compressor': { + 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + }, + 'sub_bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, + }, + 'Utility': { + 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, + }, + }, + # PAD - Filtros envolventes con width y reverb + 'pad': { + 'Auto Filter': { + 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, + 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, + 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, + 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, + }, + }, + # ATMOS - Filtros espaciales con movement + 'atmos': { + 'Auto Filter': { + 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, + 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, + 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, + 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, + }, + }, + # FX ELEMENTS + 'reverse_fx': { + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, + }, + }, + 'riser': { + 'Auto Filter': { + 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, + 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, + }, + }, + 'impact': { + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, + 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, + }, + 'Saturator': { + 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, + }, + }, + 'drone': { + 'Auto Filter': { + 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, + 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, + }, + }, + # HATS - Filtros de brillantez con resonance y saturacion + 'hat_closed': { + 'Auto Filter': { + 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, + }, + }, + 'hat_open': { + 'Auto Filter': { + 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, + }, + }, + 'top_loop': { + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, + 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, + }, + }, + # SYNTHS + 'chords': { + 'Auto Filter': { + 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, + }, + }, + 'lead': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, + 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, + }, + }, + 'stab': { + 'Saturator': { + 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, + }, + }, + 'pluck': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, + }, + }, + 'arp': { + 'Echo': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, + 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, + }, + 'Saturator': { + 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, + }, + }, + 'counter': { + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, + }, + }, + # VOCAL + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, + 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, + }, + }, + # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) + 'kick': {}, + 'clap': {}, + 'snare_fill': {}, + 'perc': {}, + 'ride': {}, + 'tom_fill': {}, + 'crash': {}, + 'sc_trigger': {}, +} + +# ============================================================================= +# ENHANCED BUS DEVICE AUTOMATION - More drive/compression per section +# ============================================================================= + +BUS_DEVICE_AUTOMATION = { + 'drums': { + 'Compressor': { + 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, + 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, + }, + 'Limiter': { + 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10000.0, 'drop': 14000.0, 'break': 6500.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.05, 'break': 0.18, 'outro': 0.14}, + }, + }, + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, + }, + 'Compressor': { + 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, + 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5000.0, 'build': 7000.0, 'drop': 10000.0, 'break': 4500.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.08, 'drop': 0.12, 'break': 0.10, 'outro': 0.06}, + }, + }, + 'music': { + 'Compressor': { + 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, + 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, + 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 14000.0, 'break': 6000.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.05, 'drop': 0.03, 'break': 0.12, 'outro': 0.10}, + }, + 'Utility': { + 'Stereo Width': {'intro': 1.05, 'build': 1.10, 'drop': 1.12, 'break': 1.18, 'outro': 1.08}, + }, + 'Saturator': { + 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, + }, + }, + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.05, 'break': 0.15, 'outro': 0.08}, + 'Feedback': {'intro': 0.25, 'build': 0.38, 'drop': 0.28, 'break': 0.45, 'outro': 0.30}, + }, + 'Compressor': { + 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, + 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.04, 'build': 0.08, 'drop': 0.03, 'break': 0.12, 'outro': 0.06}, + 'Decay Time': {'intro': 2.0, 'build': 2.8, 'drop': 1.5, 'break': 3.5, 'outro': 2.5}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 13000.0, 'break': 7200.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.04, 'break': 0.14, 'outro': 0.08}, + }, + }, + 'fx': { + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12000.0, 'break': 5500.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.06, 'break': 0.18, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.22, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.10, 'break': 0.22, 'outro': 0.16}, + 'Decay Time': {'intro': 2.5, 'build': 3.2, 'drop': 2.0, 'break': 4.0, 'outro': 3.0}, + }, + 'Limiter': { + 'Gain': {'intro': -0.2, 'build': 0.0, 'drop': 0.2, 'break': -0.3, 'outro': -0.1}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 2.0, 'break': 0.8, 'outro': 0.6}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.10}, + }, + }, +} + +# ============================================================================= +# ENHANCED MASTER Device Automation - Section Energy Response +# ============================================================================= + +MASTER_DEVICE_AUTOMATION = { + 'Utility': { + 'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, + 'Gain': {'intro': 0.6, 'build': 0.8, 'drop': 1.0, 'break': 0.5, 'outro': 0.5}, + }, + 'Saturator': { + 'Drive': {'intro': 0.2, 'build': 0.35, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.08, 'outro': 0.12}, + }, + 'Compressor': { + 'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.70, 'break': 0.50, 'outro': 0.52}, + 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -14.0, 'break': -8.0, 'outro': -9.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, + 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.16}, + }, + 'Limiter': { + 'Gain': {'intro': 1.0, 'build': 1.2, 'drop': 1.4, 'break': 0.9, 'outro': 0.95}, + 'Ceiling': {'intro': -0.5, 'build': -0.8, 'drop': -1.0, 'break': -0.3, 'outro': -0.4}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 15000.0, 'break': 6000.0, 'outro': 7000.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.03, 'drop': 0.02, 'break': 0.08, 'outro': 0.06}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.02, 'build': 0.06, 'drop': 0.04, 'break': 0.08, 'outro': 0.04}, + 'Feedback': {'intro': 0.15, 'build': 0.28, 'drop': 0.20, 'break': 0.32, 'outro': 0.22}, + }, +} + +# Safety clamps for device parameters to prevent extreme values +DEVICE_PARAMETER_SAFETY_CLAMPS = { + 'Drive': {'min': 0.0, 'max': 6.0}, + 'Frequency': {'min': 20.0, 'max': 20000.0}, + 'Dry/Wet': {'min': 0.0, 'max': 1.0}, + 'Feedback': {'min': 0.0, 'max': 0.7}, + 'Stereo Width': {'min': 0.0, 'max': 1.3}, + 'Resonance': {'min': 0.0, 'max': 1.0}, + 'Ratio': {'min': 1.0, 'max': 20.0}, + 'Threshold': {'min': -60.0, 'max': 0.0}, + 'Attack': {'min': 0.0001, 'max': 0.5}, + 'Release': {'min': 0.001, 'max': 2.0}, + 'Gain': {'min': -1.0, 'max': 1.8}, + 'Decay Time': {'min': 0.1, 'max': 10.0}, +} + +MASTER_SAFETY_CLAMPS = { + 'Stereo Width': {'min': 0.0, 'max': 1.25}, + 'Drive': {'min': 0.0, 'max': 1.5}, + 'Ratio': {'min': 0.45, 'max': 0.9}, + 'Gain': {'min': 0.0, 'max': 1.6}, + 'Attack': {'min': 0.0001, 'max': 0.1}, + 'Ceiling': {'min': -3.0, 'max': 0.0}, +} \ No newline at end of file diff --git a/AbletonMCP_AI/MCP_Server/reference_listener.py b/AbletonMCP_AI/MCP_Server/reference_listener.py new file mode 100644 index 0000000..597a928 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/reference_listener.py @@ -0,0 +1,4774 @@ +""" +reference_listener.py - Reference-track audio analysis and sample matching. + +Improved for Phase 4: +- Enhanced section detection (intro, verse, build, drop, break, outro) +- Better role detection per segment +- Precise one-shot vs loop classification +- Improved clap, hat, bass loop, vocal, fx detection +- Family repetition penalty system +""" + +from __future__ import annotations + +import json +import logging +import math +import random +import warnings +import gzip +import hashlib +import time +from collections import defaultdict, deque +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + import torch + import torch.nn.functional as F +except ImportError: # pragma: no cover + torch = None + F = None + +try: + import torch_directml +except ImportError: # pragma: no cover + torch_directml = None + + +logger = logging.getLogger("ReferenceListener") + +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] +KEY_PROFILES = { + 'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], + 'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], +} + +_cross_generation_reference_family_memory: Dict[str, int] = defaultdict(int) +_cross_generation_reference_path_memory: Dict[str, int] = defaultdict(int) + +# Section type definitions with characteristic energy patterns +# Enhanced with clearer energy thresholds and additional features for robust detection +SECTION_PROFILES = { + 'intro': { + 'energy_range': (0.0, 0.35), + 'onset_density': (0.0, 0.4), + 'spectral_brightness': (0.0, 0.5), + 'energy_stability': (0.4, 1.0), + 'typical_position': (0.0, 0.15), + 'min_bars': 4, + 'max_bars': 32, + }, + 'verse': { + 'energy_range': (0.25, 0.55), + 'onset_density': (0.3, 0.6), + 'spectral_brightness': (0.3, 0.6), + 'energy_stability': (0.5, 1.0), + 'typical_position': (0.1, 0.7), + 'min_bars': 8, + 'max_bars': 32, + }, + 'build': { + 'energy_range': (0.45, 0.85), + 'onset_density': (0.5, 0.9), + 'spectral_brightness': (0.5, 0.8), + 'energy_stability': (0.0, 0.6), + 'energy_slope': (0.05, 1.0), + 'typical_position': (0.15, 0.85), + 'min_bars': 4, + 'max_bars': 24, + 'rising': True, + }, + 'drop': { + 'energy_range': (0.65, 1.0), + 'onset_density': (0.5, 1.0), + 'spectral_brightness': (0.5, 1.0), + 'energy_stability': (0.5, 1.0), + 'typical_position': (0.2, 0.9), + 'min_bars': 8, + 'max_bars': 64, + }, + 'break': { + 'energy_range': (0.1, 0.45), + 'onset_density': (0.1, 0.4), + 'spectral_brightness': (0.2, 0.5), + 'energy_stability': (0.4, 1.0), + 'typical_position': (0.3, 0.7), + 'min_bars': 4, + 'max_bars': 24, + }, + 'outro': { + 'energy_range': (0.05, 0.4), + 'onset_density': (0.05, 0.5), + 'spectral_brightness': (0.1, 0.4), + 'energy_stability': (0.0, 0.6), + 'energy_slope': (-1.0, -0.02), + 'typical_position': (0.82, 1.0), + 'min_bars': 4, + 'max_bars': 32, + 'falling': True, + }, +} + +SECTION_CONFIDENCE_THRESHOLDS = { + 'high': 0.75, + 'medium': 0.55, + 'low': 0.35, + 'ambiguous': 0.20, +} + +# Spectral signatures for role detection +SPECTRAL_ROLE_SIGNATURES = { + 'kick': {'centroid_range': (50, 400), 'rolloff_range': (200, 2000), 'rms_spread': (0.4, 1.0), 'transient_score': (0.6, 1.0)}, + 'clap': {'centroid_range': (800, 4000), 'rolloff_range': (2000, 8000), 'rms_spread': (0.2, 0.7), 'transient_score': (0.7, 1.0)}, + 'hat': {'centroid_range': (4000, 12000), 'rolloff_range': (6000, 14000), 'rms_spread': (0.1, 0.4), 'transient_score': (0.5, 1.0)}, + 'bass_loop': {'centroid_range': (60, 500), 'rolloff_range': (200, 2000), 'rms_spread': (0.5, 1.0), 'periodicity': (0.6, 1.0)}, + 'vocal': {'centroid_range': (200, 3000), 'rolloff_range': (1000, 5000), 'rms_spread': (0.3, 0.8), 'harmonic_ratio': (0.4, 0.9)}, + 'fx': {'centroid_range': (1000, 8000), 'rolloff_range': (3000, 12000), 'rms_spread': (0.2, 0.9), 'spectral_flux': (0.5, 1.0)}, +} + +# Roles elegibles para variación por sección +# Estos roles pueden usar diferentes samples en diferentes secciones +SECTION_VARIATION_ROLES = [ + 'perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos' +] + +# Variaciones permitidas por tipo de sección +SECTION_VARIANTS = { + 'intro': ['sparse', 'minimal'], + 'verse': ['standard', 'sparse'], + 'build': ['building', 'dense'], + 'drop': ['full', 'peak'], + 'break': ['sparse', 'atmospheric'], + 'outro': ['fading', 'minimal'] +} + +ROLE_VECTOR_TYPES = { + 'kick': set(), + 'snare': set(), + 'hat': set(), + 'bass_loop': {'bass'}, + 'perc_loop': {'drum loop', 'top'}, + 'top_loop': {'top', 'drum loop'}, + 'synth_loop': {'synth loop', 'synth'}, + 'vocal_loop': {'vocal'}, + 'crash_fx': {'fx'}, + 'fill_fx': {'fx'}, + 'snare_roll': {'fx'}, + 'atmos_fx': {'fx', 'synth'}, + 'vocal_shot': {'vocal'}, +} + +ROLE_SEGMENT_SETTINGS = { + 'kick': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 10}, + 'snare': {'windows': {1.0, 2.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 10}, + 'hat': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 12}, + 'bass_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'perc_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'top_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'synth_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'build', 'drop', 'break'}, 'top_k': 8}, + 'vocal_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop', 'break'}, 'top_k': 8}, + 'crash_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop', 'intro', 'outro'}, 'top_k': 6}, + 'fill_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'break', 'drop'}, 'top_k': 6}, + 'snare_roll': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop'}, 'top_k': 6}, + 'atmos_fx': {'windows': {4.0, 8.0}, 'section_kinds': {'intro', 'break', 'outro'}, 'top_k': 6}, + 'vocal_shot': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, +} + +ROLE_DURATION_WINDOWS = { + 'kick': (0.05, 2.5), + 'snare': (0.05, 3.0), + 'hat': (0.05, 2.0), + 'bass_loop': (0.75, 32.0), + 'perc_loop': (0.75, 32.0), + 'top_loop': (0.75, 32.0), + 'synth_loop': (0.75, 32.0), + 'vocal_loop': (0.75, 32.0), + 'crash_fx': (0.05, 12.0), + 'fill_fx': (0.15, 12.0), + 'snare_roll': (0.15, 12.0), + 'atmos_fx': (0.25, 32.0), + 'vocal_shot': (0.05, 3.5), +} + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(np.atleast_1d(value)[0]) + except Exception: + return float(default) + + +def _normalize_chroma(chroma: np.ndarray) -> np.ndarray: + chroma = np.asarray(chroma, dtype=np.float32).reshape(12) + total = float(np.sum(chroma)) + if total <= 1e-9: + return chroma + return chroma / total + + +def _adaptive_n_fft(audio_length: int, default_n_fft: int = 2048, min_n_fft: int = 512) -> int: + """Calcula n_fft adaptativo basado en la longitud del audio.""" + max_n_fft = audio_length // 2 + adaptive = max(min_n_fft, min(default_n_fft, max_n_fft)) + if adaptive < default_n_fft: + logger.debug("Using reduced n_fft=%d for short audio (len=%d)", adaptive, audio_length) + return adaptive + + +def _detect_key(chroma: np.ndarray) -> Tuple[Optional[str], float]: + chroma = _normalize_chroma(chroma) + best_key = None + best_score = -999.0 + + for mode, profile in KEY_PROFILES.items(): + profile_array = np.asarray(profile, dtype=np.float32) + for index in range(12): + score = np.corrcoef(chroma, np.roll(profile_array, index))[0, 1] + if np.isnan(score): + continue + if score > best_score: + best_score = float(score) + best_key = NOTE_NAMES[index] + ('m' if mode == 'minor' else '') + + return best_key, best_score if best_key else 0.0 + + +def _key_distance(left: Optional[str], right: Optional[str]) -> int: + if not left or not right: + return 6 + + def _index(key_name: str) -> int: + base = key_name[:-1] if key_name.endswith('m') else key_name + return NOTE_NAMES.index(base) if base in NOTE_NAMES else 0 + + return min((_index(left) - _index(right)) % 12, (_index(right) - _index(left)) % 12) + + +class SectionDetector: + """Detects structural sections from audio analysis with improved segmentation.""" + + def __init__(self, hop_length: int = 512, sr: int = 22050): + self.hop_length = hop_length + self.sr = sr + self.min_section_bars = 4 + self.max_section_bars = 64 + self.min_section_seconds = 6.0 + self.max_section_seconds = 120.0 + self.energy_smoothing_window = 2.0 + self.boundary_sensitivity = 0.65 + self.min_energy_diff_for_boundary = 0.08 + self.ambiguity_threshold = 0.25 + + def _compute_segment_features(self, rms: np.ndarray, onset: np.ndarray, + centroid: np.ndarray, start_frame: int, + end_frame: int, rms_global_max: float = None) -> Dict[str, float]: + """Compute normalized features for a segment.""" + rms_seg = rms[start_frame:end_frame] + onset_seg = onset[start_frame:end_frame] + centroid_seg = centroid[start_frame:end_frame] + + if len(rms_seg) == 0: + return {'energy': 0.0, 'onset_density': 0.0, 'brightness': 0.0, 'flux': 0.0, + 'energy_stability': 1.0, 'onset_variability': 0.0} + + rms_global_max = rms_global_max if rms_global_max is not None else float(np.max(rms)) + rms_global_max = max(rms_global_max, 0.001) + + energy = float(np.mean(rms_seg)) + onset_density = float(np.mean(onset_seg)) / 5.0 + brightness = float(np.mean(centroid_seg)) / 10000.0 + + if len(centroid_seg) > 1: + flux = float(np.mean(np.abs(np.diff(centroid_seg)))) / 2000.0 + else: + flux = 0.0 + + energy_stability = 1.0 + if len(rms_seg) > 1: + energy_cv = float(np.std(rms_seg)) / max(float(np.mean(rms_seg)), 0.001) + energy_stability = min(1.0, max(0.0, 1.0 - energy_cv * 2.0)) + + onset_variability = 0.0 + if len(onset_seg) > 1: + onset_std = float(np.std(onset_seg)) + onset_mean = max(float(np.mean(onset_seg)), 0.001) + onset_variability = min(1.0, onset_std / onset_mean) + + return { + 'energy': min(1.0, max(0.0, (energy / rms_global_max) * 1.5)), + 'onset_density': min(1.0, max(0.0, onset_density)), + 'brightness': min(1.0, max(0.0, brightness)), + 'flux': min(1.0, max(0.0, flux)), + 'energy_stability': round(energy_stability, 3), + 'onset_variability': round(onset_variability, 3) + } + + def _compute_richer_section_features( + self, + y: np.ndarray, + sr: int, + rms: np.ndarray, + onset_env: np.ndarray, + centroid: np.ndarray, + start_time: float, + end_time: float, + hop_length: int = 512, + n_fft: int = 2048 + ) -> Dict[str, float]: + """ + Compute richer per-section features for better reference matching. + + Returns energy_mean, energy_peak, energy_slope, spectral_centroid_mean, + spectral_centroid_std, onset_rate, low_energy_ratio, high_energy_ratio. + """ + duration = end_time - start_time + if duration < 1.0: + return { + 'energy_mean': 0.0, + 'energy_peak': 0.0, + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.0, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.0, + 'low_energy_ratio': 0.0, + 'high_energy_ratio': 0.0, + } + + frames_per_second = sr / hop_length + start_frame = int(start_time * frames_per_second) + end_frame = int(end_time * frames_per_second) + + start_frame = max(0, min(start_frame, len(rms) - 1)) + end_frame = max(start_frame + 1, min(end_frame, len(rms))) + + section_rms = rms[start_frame:end_frame] + section_onset = onset_env[start_frame:end_frame] + section_centroid = centroid[start_frame:end_frame] + + if len(section_rms) == 0: + return { + 'energy_mean': 0.0, + 'energy_peak': 0.0, + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.0, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.0, + 'low_energy_ratio': 0.0, + 'high_energy_ratio': 0.0, + } + + # Energy metrics (normalized 0-1) + rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 + energy_mean = float(np.mean(section_rms)) + energy_peak = float(np.max(section_rms)) + energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) + energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) + + # Energy slope (trend within section) + if len(section_rms) > 2: + x = np.arange(len(section_rms)) + slope, _ = np.polyfit(x, section_rms, 1) + energy_slope_norm = float(np.clip(slope * 100, -1.0, 1.0)) + else: + energy_slope_norm = 0.0 + + # Spectral centroid metrics + centroid_mean = float(np.mean(section_centroid)) + centroid_std = float(np.std(section_centroid)) if len(section_centroid) > 1 else 0.0 + centroid_mean_norm = min(1.0, centroid_mean / 10000.0) + centroid_std_norm = min(1.0, centroid_std / 6000.0) + + # Onset rate (onsets per second) + onset_threshold = float(np.mean(section_onset)) + float(np.std(section_onset)) * 0.5 + onset_count = int(np.sum(section_onset > onset_threshold)) + onset_rate = onset_count / max(duration, 0.1) + onset_rate_norm = min(1.0, onset_rate / 20.0) + + # Low and high energy ratios (STFT-based frequency analysis) + start_sample = int(start_time * sr) + end_sample = int(end_time * sr) + start_sample = max(0, min(start_sample, len(y) - 1)) + end_sample = max(start_sample + 512, min(end_sample, len(y))) + + try: + S = np.abs(librosa.stft(y[start_sample:end_sample], n_fft=n_fft)) + freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft) + total_energy = float(np.sum(S ** 2)) + 1e-10 + + low_mask = freqs < 300 + high_mask = freqs > 4000 + + low_energy = float(np.sum(S[low_mask, :] ** 2)) + high_energy = float(np.sum(S[high_mask, :] ** 2)) + + low_energy_ratio = min(1.0, low_energy / total_energy) + high_energy_ratio = min(1.0, high_energy / total_energy) + except Exception: + low_energy_ratio = 0.0 + high_energy_ratio = 0.0 + + return { + 'energy_mean': round(energy_mean_norm, 4), + 'energy_peak': round(energy_peak_norm, 4), + 'energy_slope': round(energy_slope_norm, 4), + 'spectral_centroid_mean': round(centroid_mean_norm, 4), + 'spectral_centroid_std': round(centroid_std_norm, 4), + 'onset_rate': round(onset_rate_norm, 4), + 'low_energy_ratio': round(low_energy_ratio, 4), + 'high_energy_ratio': round(high_energy_ratio, 4), + } + + def _compute_section_kind_confidence( + self, + kind: str, + features: Dict[str, float], + position_ratio: float, + prev_features: Optional[Dict[str, float]] + ) -> Tuple[float, List[str]]: + """ + Compute confidence score for section kind classification. + + Returns (confidence, alternatives) where: + - confidence is 0.0-1.0 with clear semantic thresholds: + - 0.75+: high confidence (section type is clear) + - 0.55-0.75: medium confidence (likely correct but could be alternative) + - 0.35-0.55: low confidence (ambiguous, check alternatives) + - <0.35: very low confidence (section may be misclassified) + - alternatives is list of 1-2 other plausible kinds + + Enhanced with energy trend, onset variability, positional context, and feature matching. + """ + energy = features.get('energy', 0.5) + onset_density = features.get('onset_density', 0.5) + onset_var = features.get('onset_variability', 0.0) + stability = features.get('energy_stability', 1.0) + brightness = features.get('brightness', 0.5) + + energy_mean = features.get('energy_mean', energy) + onset_rate = features.get('onset_rate', onset_density) + + energy_trend = features.get('energy_trend', 0.0) + if energy_trend == 0.0 and prev_features: + prev_energy = prev_features.get('energy', energy) + energy_trend = energy - prev_energy + + profile = SECTION_PROFILES.get(kind, {}) + confidence = 0.35 + alternatives = [] + + prev_energy = prev_features.get('energy', energy) if prev_features else energy + energy_rising = energy_trend > 0.08 + energy_falling = energy_trend < -0.08 + + def _match_range(value: float, range_tuple: Tuple[float, float]) -> float: + if not range_tuple: + return 0.5 + lo, hi = range_tuple + if lo <= value <= hi: + center = (lo + hi) / 2 + spread = (hi - lo) / 2 + dist_from_center = abs(value - center) + return 1.0 - (dist_from_center / (spread * 2 + 0.01)) + elif value < lo: + return max(0.0, 1.0 - (lo - value) * 2) + else: + return max(0.0, 1.0 - (value - hi) * 2) + + energy_match = _match_range(energy_mean, profile.get('energy_range', (0.0, 1.0))) + onset_match = _match_range(onset_rate, profile.get('onset_density', (0.0, 1.0))) + brightness_match = _match_range(brightness, profile.get('spectral_brightness', (0.0, 1.0))) + stability_match = _match_range(stability, profile.get('energy_stability', (0.0, 1.0))) + + pos_range = profile.get('typical_position', (0.0, 1.0)) + position_match = _match_range(position_ratio, pos_range) + + base_feature_score = (energy_match * 0.35 + onset_match * 0.25 + brightness_match * 0.15 + stability_match * 0.15 + position_match * 0.10) + + if kind == 'intro': + if prev_features is None: + confidence = 0.85 + base_feature_score * 0.15 + elif position_ratio < 0.12 and energy_mean < 0.32: + confidence = 0.78 + base_feature_score * 0.18 + elif position_ratio < 0.18 and energy_mean < 0.40: + confidence = 0.62 + base_feature_score * 0.15 + elif position_ratio < 0.22 and energy_mean < 0.45: + confidence = 0.48 + base_feature_score * 0.12 + else: + confidence = 0.30 + base_feature_score * 0.10 + if energy_mean > 0.55: + confidence -= 0.18 + if energy_rising and position_ratio > 0.1: + confidence -= 0.10 + alternatives = ['verse', 'break', 'build'] + + elif kind == 'outro': + if position_ratio > 0.90: + confidence = 0.88 + base_feature_score * 0.12 + elif position_ratio > 0.85 and energy_mean < 0.35: + confidence = 0.75 + base_feature_score * 0.15 + elif position_ratio > 0.80 and energy_mean < 0.42: + confidence = 0.58 + base_feature_score * 0.12 + else: + confidence = 0.32 + base_feature_score * 0.08 + if energy_falling: + confidence += 0.12 + if energy_mean > 0.55: + confidence -= 0.12 + alternatives = ['break', 'verse', 'build'] + + elif kind == 'drop': + if energy_mean > 0.72 and onset_rate > 0.48 and stability > 0.55: + confidence = 0.92 + (energy_mean - 0.72) * 0.3 + elif energy_mean > 0.62 and onset_rate > 0.40: + confidence = 0.78 + base_feature_score * 0.15 + elif energy_mean > 0.52 and onset_rate > 0.35: + confidence = 0.55 + base_feature_score * 0.12 + else: + confidence = 0.30 + base_feature_score * 0.08 + if 0.25 < position_ratio < 0.75: + confidence += 0.05 + if position_ratio < 0.18: + confidence -= 0.15 + alternatives = ['build', 'verse'] + + elif kind == 'build': + slope_range = profile.get('energy_slope', (0.0, 1.0)) + slope_match = _match_range(energy_trend, slope_range) if slope_range else 0.5 + + if energy_rising and 0.40 < energy_mean < 0.72: + confidence = 0.82 + slope_match * 0.15 + if onset_var > 0.25: + confidence = min(confidence + 0.08, 0.95) + elif energy_rising and 0.35 < energy_mean < 0.78: + confidence = 0.62 + slope_match * 0.18 + elif 0.35 < energy_mean < 0.72 and not energy_falling: + confidence = 0.45 + base_feature_score * 0.15 + else: + confidence = 0.28 + base_feature_score * 0.08 + if position_ratio < 0.12 or position_ratio > 0.88: + confidence -= 0.12 + alternatives = ['drop', 'verse', 'break'] + + elif kind == 'break': + if energy_mean < 0.35 and onset_rate < 0.30 and stability > 0.50: + confidence = 0.85 + base_feature_score * 0.12 + elif energy_mean < 0.42 and onset_rate < 0.38: + confidence = 0.65 + base_feature_score * 0.10 + elif energy_mean < 0.48 and onset_rate < 0.45: + confidence = 0.42 + base_feature_score * 0.08 + else: + confidence = 0.28 + base_feature_score * 0.06 + if 0.25 < position_ratio < 0.75: + confidence += 0.06 + if brightness > 0.55: + confidence -= 0.06 + alternatives = ['intro', 'outro', 'verse'] + + elif kind == 'verse': + if 0.25 < energy_mean < 0.58 and 0.25 < onset_rate < 0.65 and stability > 0.45: + confidence = 0.72 + base_feature_score * 0.15 + elif 0.28 < energy_mean < 0.55: + confidence = 0.52 + base_feature_score * 0.12 + else: + confidence = 0.35 + base_feature_score * 0.08 + if 0.15 < position_ratio < 0.75: + confidence += 0.05 + alternatives = ['build', 'drop', 'break'] + + else: + confidence = 0.40 + base_feature_score * 0.10 + alternatives = ['verse', 'drop'] + + total_sections = features.get('total_sections', 4) + if total_sections <= 2: + confidence = min(confidence * 0.90, 0.95) + elif total_sections >= 8: + pass + + confidence = max(0.15, min(0.98, confidence)) + + return round(confidence, 3), alternatives + + def _section_character_bonus( + self, + role: str, + candidate_analysis: Dict[str, Any], + section_features: Dict[str, Any] + ) -> float: + """ + Compute a character bonus for matching a candidate sample to a section. + + Returns a multiplier (1.0 = no change, max ~1.25) based on how well + the candidate's features match the section's acoustic character. + """ + if not section_features: + return 1.0 + + bonus = 1.0 + + onset_rate = float(section_features.get('onset_rate', 0.5)) + low_energy_ratio = float(section_features.get('low_energy_ratio', 0.0)) + high_energy_ratio = float(section_features.get('high_energy_ratio', 0.0)) + energy_slope = float(section_features.get('energy_slope', 0.0)) + energy_mean = float(section_features.get('energy_mean', 0.5)) + + candidate_centroid = float(candidate_analysis.get('spectral_centroid', 0.0) or 0.0) + candidate_onset = float(candidate_analysis.get('onset_mean', 0.0) or 0.0) + + role_lower = role.lower() + + # High onset rate section + high onset density candidate = bonus + if onset_rate > 0.4: + candidate_onset_norm = min(1.0, candidate_onset / 5.0) + if role_lower in {'hat', 'top_loop', 'perc_loop', 'perc'}: + if candidate_onset_norm > 0.6: + bonus = max(bonus, 1.0 + (candidate_onset_norm - 0.5) * 0.25) + + # High low-energy ratio + bass role = bonus + if low_energy_ratio > 0.4: + candidate_low_centroid = max(0.0, 1.0 - candidate_centroid / 3000.0) + if role_lower in {'bass_loop', 'sub_bass', 'bass'}: + if candidate_low_centroid > 0.5: + bonus = max(bonus, 1.0 + candidate_low_centroid * 0.15) + + # High high-energy ratio + hat/top role = bonus + if high_energy_ratio > 0.3: + candidate_high_centroid = min(1.0, candidate_centroid / 10000.0) + if role_lower in {'hat', 'top_loop', 'crash_fx'}: + if candidate_high_centroid > 0.5: + bonus = max(bonus, 1.0 + candidate_high_centroid * 0.12) + + # Building section (positive slope) + snare_roll/fill_fx = bonus + if energy_slope > 0.1: + if role_lower in {'snare_roll', 'fill_fx', 'riser'}: + bonus = max(bonus, 1.0 + energy_slope * 0.25) + + # Low energy section + atmos_fx = bonus + if energy_mean < 0.3: + if role_lower in {'atmos_fx', 'atmos', 'pad'}: + bonus = max(bonus, 1.0 + (0.3 - energy_mean) * 0.4) + + return min(1.25, max(1.0, round(bonus, 3))) + + def _get_role_section_features( + self, role: str, reference_sections: List[Dict[str, Any]], + role_segments: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """Get the most relevant section features for a given role.""" + if not reference_sections: + return {} + + role_lower = role.lower() + + preferred_kinds: Dict[str, List[str]] = { + 'kick': ['drop', 'build'], + 'snare': ['drop', 'build'], + 'hat': ['drop', 'verse'], + 'bass_loop': ['drop', 'build'], + 'sub_bass': ['drop', 'build'], + 'top_loop': ['drop', 'verse'], + 'perc_loop': ['drop', 'build'], + 'synth_loop': ['drop', 'verse'], + 'vocal_loop': ['drop', 'verse'], + 'vocal_shot': ['drop', 'verse'], + 'snare_roll': ['build', 'intro'], + 'fill_fx': ['build', 'break'], + 'riser': ['build', 'intro'], + 'crash_fx': ['drop', 'intro', 'outro'], + 'atmos_fx': ['break', 'intro', 'outro'], + 'atmos': ['break', 'intro', 'outro'], + 'pad': ['break', 'intro'], + } + + kinds = preferred_kinds.get(role_lower, ['drop']) + + for section in reference_sections: + kind = str(section.get('kind', 'drop')).lower() + if kind in kinds: + return section.get('features', {}) + + if reference_sections: + for section in reference_sections: + if section.get('kind', 'drop') == 'drop': + return section.get('features', {}) + return reference_sections[0].get('features', {}) + + return {} + + def _find_boundary_peaks(self, energy_diff: np.ndarray, onset_peaks: np.ndarray, + threshold: float, min_gap_frames: int) -> List[int]: + """Find section boundary peaks combining energy changes and onset peaks with improved detection.""" + if len(energy_diff) == 0: + return [] + + threshold_val = float(threshold) + + energy_percentile = float(np.percentile(energy_diff, 75)) if len(energy_diff) > 10 else threshold_val + onset_percentile = float(np.percentile(onset_peaks, 55)) + + candidates = [] + for i in range(len(energy_diff)): + energy_score = float(energy_diff[i]) + onset_score = float(onset_peaks[i]) + + combined_score = energy_score * 0.6 + onset_score * 0.4 + + if energy_score > threshold_val and onset_score > onset_percentile * 0.8: + candidates.append((i, combined_score, 'both')) + elif energy_score > energy_percentile and onset_score > onset_percentile * 0.5: + candidates.append((i, combined_score * 0.7, 'energy')) + elif onset_score > float(np.percentile(onset_peaks, 85)) and energy_score > threshold_val * 0.5: + candidates.append((i, combined_score * 0.6, 'onset')) + + if not candidates: + for i in range(len(energy_diff)): + if float(energy_diff[i]) > threshold_val * 0.7: + candidates.append((i, float(energy_diff[i]), 'fallback')) + + candidates.sort(key=lambda x: x[1], reverse=True) + + boundaries = [] + for idx, score, method in candidates: + is_valid = True + for existing in boundaries: + if abs(idx - existing) < min_gap_frames: + is_valid = False + break + if is_valid: + boundaries.append(idx) + + boundaries.sort() + return boundaries + + def _validate_section_progression(self, sections: List[Dict[str, Any]], + duration: float, tempo: float) -> List[Dict[str, Any]]: + """Validate and fix section progression for musical coherence.""" + if not sections: + return [{'kind': 'drop', 'start': 0.0, 'end': duration, + 'duration': duration, 'bars': max(8, int(duration * tempo / 60 / 4)), + 'kind_confidence': 0.3, 'features': {'energy': 0.5}}] + + beats_per_second = tempo / 60.0 + seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 + + result = [] + for i, section in enumerate(sections): + kind = section.get('kind', 'drop') + start = section.get('start', 0.0) + end = section.get('end', duration) + sec_duration = end - start + + estimated_bars = max(4, int(round(sec_duration / seconds_per_bar))) + if estimated_bars > self.max_section_bars: + kind = 'drop' if section.get('features', {}).get('energy', 0.5) > 0.6 else 'break' + if estimated_bars < self.min_section_bars and i > 0: + prev_section = result[-1] if result else None + if prev_section and prev_section.get('kind') == kind: + prev_section['end'] = end + prev_section['duration'] = end - prev_section['start'] + prev_section['bars'] += estimated_bars + continue + + section['bars'] = estimated_bars + section['beats'] = estimated_bars * 4 + result.append(section) + + for i, section in enumerate(result): + section['section_index'] = i + section['total_sections'] = len(result) + + return result + + def _compute_energy_transitions(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Compute energy transition direction between sections.""" + if len(sections) < 2: + return sections + + for i, section in enumerate(sections): + next_section = sections[i + 1] if i < len(sections) - 1 else None + prev_section = sections[i - 1] if i > 0 else None + + current_energy = section.get('features', {}).get('energy', 0.5) + next_energy = next_section.get('features', {}).get('energy', current_energy) if next_section else current_energy + prev_energy = prev_section.get('features', {}).get('energy', current_energy) if prev_section else current_energy + + energy_diff_next = next_energy - current_energy + energy_diff_prev = current_energy - prev_energy + + if energy_diff_next > 0.15: + section['energy_transition'] = 'rising' + elif energy_diff_next < -0.15: + section['energy_transition'] = 'falling' + else: + section['energy_transition'] = 'stable' + + section['energy_delta_next'] = round(energy_diff_next, 3) + section['energy_delta_prev'] = round(energy_diff_prev, 3) + + return sections + + def detect_sections(self, rms: np.ndarray, onset: np.ndarray, + centroid: np.ndarray, duration: float, + min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: + """Detect sections from audio features with improved segmentation and edge case handling.""" + if len(rms) == 0 or duration < min_section_seconds * 1.5: + default_bars = max(8, int(duration * 128 / 60 / 4)) if duration > 0 else 8 + return [{'kind': 'drop', 'start': 0.0, 'end': duration, 'bars': default_bars, + 'duration': duration, 'kind_confidence': 0.35, + 'confidence_level': 'low', + 'features': {'energy': 0.5, 'onset_density': 0.5}, + 'detection_method': 'fallback_short_track'}] + + hop_time = self.hop_length / self.sr + frames_per_section = max(1, int(min_section_seconds / hop_time)) + + rms_global_max = float(np.max(rms)) if len(rms) > 0 else 0.01 + kernel_size = min(len(rms), max(1, int(self.energy_smoothing_window / hop_time))) + + if kernel_size > 1: + smoothed_rms = np.convolve(rms, np.ones(kernel_size) / kernel_size, mode='same') + else: + smoothed_rms = rms + + if len(smoothed_rms) > 1: + energy_diff = np.abs(np.diff(smoothed_rms)) + if len(energy_diff) > kernel_size: + energy_diff = np.convolve(energy_diff, np.ones(kernel_size) / kernel_size, mode='same') + else: + energy_diff = np.zeros(1) + + onset_binary = (onset > np.percentile(onset, 65)).astype(float) + onset_peaks = np.convolve(onset_binary, np.ones(kernel_size) / kernel_size, mode='same') + + base_threshold = max(float(np.percentile(energy_diff, 65)), 0.001) if len(energy_diff) > 10 else 0.001 + threshold = base_threshold * self.boundary_sensitivity + + primary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, float(threshold), frames_per_section) + + secondary_threshold = float(threshold) * 0.55 + secondary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, secondary_threshold, frames_per_section // 2) + + all_boundaries = sorted(set([0] + primary_boundaries + secondary_boundaries + [len(rms) - 1])) + consolidated_boundaries = [all_boundaries[0]] + for boundary in all_boundaries[1:]: + min_gap = frames_per_section * 0.4 + if boundary - consolidated_boundaries[-1] >= min_gap: + consolidated_boundaries.append(boundary) + + if len(consolidated_boundaries) < 3 and duration > min_section_seconds * 2: + _ = smoothed_rms + n_segments = max(3, min(6, int(duration / min_section_seconds))) + segment_boundaries = [0] + for i in range(1, n_segments): + target_frame = int(i * len(rms) / n_segments) + search_range = max(1, int(len(rms) / (n_segments * 2))) + best_frame = target_frame + best_diff = float('inf') + for j in range(max(0, target_frame - search_range), min(len(energy_diff), target_frame + search_range)): + if float(energy_diff[j]) > best_diff * 0.8: + best_diff = float(energy_diff[j]) + best_frame = j + segment_boundaries.append(best_frame) + segment_boundaries.append(len(rms) - 1) + consolidated_boundaries = sorted(set(consolidated_boundaries + segment_boundaries)) + + sections = [] + prev_features = None + prev_energy_trend = None + + for i in range(len(consolidated_boundaries) - 1): + start_frame = consolidated_boundaries[i] + end_frame = consolidated_boundaries[i + 1] + + if end_frame <= start_frame: + continue + + start_time = start_frame * hop_time + end_time = end_frame * hop_time + segment_duration = end_time - start_time + + min_duration = min_section_seconds * 0.2 + if segment_duration < min_duration: + if sections: + sections[-1]['end'] = end_time + sections[-1]['duration'] = end_time - sections[-1]['start'] + sections[-1]['merged_short'] = True + continue + + max_duration = self.max_section_seconds + if segment_duration > max_duration: + mid_frame = (start_frame + end_frame) // 2 + consolidated_boundaries.insert(i + 1, mid_frame) + end_frame = mid_frame + end_time = end_frame * hop_time + segment_duration = end_time - start_time + + features = self._compute_segment_features( + rms, onset, centroid, start_frame, end_frame, rms_global_max + ) + + energy = features.get('energy', 0.5) + if prev_features: + energy_trend = energy - prev_features.get('energy', 0.5) + else: + energy_trend = 0.0 + features['energy_trend'] = round(energy_trend, 3) + + position_ratio = start_time / duration if duration > 0 else 0.0 + positional_weight = self._compute_positional_weight(position_ratio, len(consolidated_boundaries) - 1, i) + + kind = self._classify_segment_v2( + features, position_ratio, prev_features, energy_trend, prev_energy_trend + ) + + estimated_bars = max(4, int(round(segment_duration * 128 / 60 / 4))) + + sections.append({ + 'kind': kind, + 'start': round(start_time, 3), + 'end': round(end_time, 3), + 'duration': round(segment_duration, 3), + 'bars': estimated_bars, + 'features': features, + 'positional_weight': positional_weight, + }) + + prev_features = features + prev_energy_trend = energy_trend + + merged = [] + for section in sections: + if merged and merged[-1]['kind'] == section['kind'] and section['duration'] < min_section_seconds * 0.6: + merged[-1]['end'] = section['end'] + merged[-1]['duration'] = round(section['end'] - merged[-1]['start'], 3) + merged[-1]['bars'] += section.get('bars', 4) + merged_features = merged[-1].get('features', {}) + new_features = section.get('features', {}) + merged_features['energy'] = (merged_features.get('energy', 0.5) + new_features.get('energy', 0.5)) / 2 + merged[-1]['merged_with_next'] = True + else: + merged.append(section) + + merged = self._validate_section_progression(merged, duration, 128.0) + merged = self._compute_energy_transitions(merged) + + merged = self._add_confidence_levels(merged) + + if len(merged) < 2 and duration > min_section_seconds * 2: + merged = self._create_fallback_sections(duration, 128.0, rms, onset) + + return merged + + def _add_confidence_levels(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Add human-readable confidence levels to sections.""" + for section in sections: + confidence = section.get('kind_confidence', 0.5) + if confidence >= SECTION_CONFIDENCE_THRESHOLDS['high']: + section['confidence_level'] = 'high' + elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['medium']: + section['confidence_level'] = 'medium' + elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['low']: + section['confidence_level'] = 'low' + else: + section['confidence_level'] = 'ambiguous' + return sections + + def _create_fallback_sections(self, duration: float, tempo: float, + rms: np.ndarray, onset: np.ndarray) -> List[Dict[str, Any]]: + """Create fallback sections when detection fails.""" + sections = [] + beats_per_second = tempo / 60.0 + seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 + + total_bars = max(16, int(duration / seconds_per_bar)) + + if duration < 60: + sections = [ + {'kind': 'intro', 'start': 0.0, 'end': duration * 0.25, + 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.3}, 'detection_method': 'fallback'}, + {'kind': 'drop', 'start': duration * 0.25, 'end': duration * 0.75, + 'duration': duration * 0.5, 'bars': max(8, int(total_bars * 0.5)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.6}, 'detection_method': 'fallback'}, + {'kind': 'outro', 'start': duration * 0.75, 'end': duration, + 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.35}, 'detection_method': 'fallback'}, + ] + else: + n_sections = min(5, max(3, int(duration / 30))) + section_duration = duration / n_sections + + energy_profile = [] + if len(rms) > n_sections: + segment_size = len(rms) // n_sections + for i in range(n_sections): + segment_rms = rms[i * segment_size:(i + 1) * segment_size] + energy_profile.append(float(np.mean(segment_rms)) if len(segment_rms) > 0 else 0.5) + max_energy = max(energy_profile) if energy_profile else 0.5 + energy_profile = [e / max_energy for e in energy_profile] + else: + energy_profile = [0.3, 0.5, 0.7, 0.6, 0.4][:n_sections] + + kinds = ['intro', 'verse', 'build', 'drop', 'outro'] + for i in range(n_sections): + kind = kinds[i] if i < len(kinds) else 'verse' + if i == n_sections - 1: + kind = 'outro' + elif i == 0: + kind = 'intro' + elif i == n_sections - 2: + kind = 'drop' + elif energy_profile[i] > 0.6 and i > 0 and i < n_sections - 1: + kind = 'drop' + + start = i * section_duration + end = (i + 1) * section_duration if i < n_sections - 1 else duration + + sections.append({ + 'kind': kind, + 'start': round(start, 3), + 'end': round(end, 3), + 'duration': round(end - start, 3), + 'bars': max(4, int((end - start) / seconds_per_bar)), + 'kind_confidence': 0.30, + 'confidence_level': 'low', + 'features': {'energy': energy_profile[i] if i < len(energy_profile) else 0.5}, + 'detection_method': 'fallback_energy_profile', + }) + + return sections + + def _compute_positional_weight(self, position_ratio: float, total_sections: int, + section_index: int) -> float: + """Compute positional weight for section classification confidence.""" + if total_sections <= 1: + return 1.0 + + if position_ratio < 0.15: + return 1.2 + elif position_ratio > 0.85: + return 1.2 + elif 0.35 < position_ratio < 0.65: + return 0.9 + else: + return 1.0 + + def _classify_segment_v2(self, features: Dict[str, float], position_ratio: float, + prev_features: Optional[Dict[str, float]], + energy_trend: float, prev_energy_trend: Optional[float]) -> str: + """Classify segment with improved energy trend and context awareness.""" + energy = features.get('energy', 0.5) + onset = features.get('onset_density', 0.5) + brightness = features.get('brightness', 0.5) + stability = features.get('energy_stability', 1.0) + onset_var = features.get('onset_variability', 0.0) + + is_rising = energy_trend > 0.08 or (prev_energy_trend is not None and prev_energy_trend > 0.05 and energy_trend >= 0) + is_falling = energy_trend < -0.08 or (prev_energy_trend is not None and prev_energy_trend < -0.05) + + is_strong_rise = energy_trend > 0.15 + _ = energy_trend < -0.15 + + scores = {} + + if position_ratio < 0.18: + intro_energy_match = max(0, 0.5 - abs(energy - 0.22)) + intro_onset_match = max(0, 0.4 - abs(onset - 0.22)) + intro_pos_bonus = 0.65 * (0.18 - position_ratio) + intro_stability_bonus = 0.15 if stability > 0.5 else 0 + scores['intro'] = intro_energy_match + intro_onset_match + intro_pos_bonus + intro_stability_bonus + else: + scores['intro'] = -0.5 + + if position_ratio > 0.80: + outro_energy_match = max(0, 0.5 - abs(energy - 0.22)) + outro_onset_match = max(0, 0.4 - abs(onset - 0.22)) + outro_pos_bonus = 0.55 * (position_ratio - 0.80) + outro_falling_bonus = 0.25 if is_falling else (0.10 if not is_rising else -0.15) + scores['outro'] = outro_energy_match + outro_onset_match + outro_pos_bonus + outro_falling_bonus + else: + scores['outro'] = -0.2 + + if is_strong_rise and 0.38 < energy < 0.75: + scores['build'] = 0.85 + (abs(energy_trend) * 1.5) + (onset * 0.25) + elif is_rising and 0.35 < energy < 0.78: + scores['build'] = 0.55 + (abs(energy_trend) * 2.0) + (onset * 0.15) + elif 0.35 < energy < 0.72 and onset > 0.45 and position_ratio < 0.75: + scores['build'] = 0.38 + (onset * 0.25) + elif 0.38 < energy < 0.65 and onset_var > 0.2: + scores['build'] = 0.32 + (onset_var * 0.3) + else: + scores['build'] = max(0, 0.15 - abs(energy_trend) * 2) if energy_trend < 0.05 else 0.08 + + if energy > 0.68 and onset > 0.48 and stability > 0.55: + brightness_bonus = 0.12 if brightness > 0.5 else 0 + scores['drop'] = (energy - 0.50) * 1.4 + (onset - 0.40) * 0.7 + brightness_bonus + elif energy > 0.60 and onset > 0.42: + scores['drop'] = (energy - 0.50) * 1.1 + onset * 0.45 + elif energy > 0.52: + scores['drop'] = 0.35 + (energy - 0.52) * 1.5 + else: + scores['drop'] = max(-0.3, (energy - 0.45) * 2) + + if energy < 0.40 and onset < 0.32 and stability > 0.45: + scores['break'] = 0.75 + (0.40 - energy) * 0.55 + (0.32 - onset) * 0.45 + elif energy < 0.48 and onset < 0.38 and not is_rising: + scores['break'] = 0.45 + (0.48 - energy) * 0.35 + (0.38 - onset) * 0.25 + elif energy < 0.45 and brightness < 0.45: + scores['break'] = 0.35 + (0.45 - energy) * 0.3 + else: + scores['break'] = max(0, 0.08 - abs(energy - 0.35) - abs(onset - 0.32)) + + if 0.22 < energy < 0.60 and 0.22 < onset < 0.68 and stability > 0.40: + scores['verse'] = 0.55 - abs(energy - 0.42) * 1.5 - abs(onset - 0.42) * 1.2 + elif 0.28 < energy < 0.52 and not is_rising and not is_falling: + scores['verse'] = 0.38 - abs(energy - 0.40) * 1.0 + elif 0.25 < energy < 0.55: + scores['verse'] = 0.25 + else: + scores['verse'] = 0.12 + + if not scores: + return 'drop' + + best_kind, best_score = max(scores.items(), key=lambda x: x[1]) + + if best_score < 0.10: + if energy > 0.52: + return 'drop' + elif position_ratio < 0.18: + return 'intro' + elif position_ratio > 0.82: + return 'outro' + elif energy < 0.42: + return 'break' + elif is_rising: + return 'build' + else: + return 'verse' + + second_best = sorted(scores.items(), key=lambda x: x[1], reverse=True) + if len(second_best) > 1: + score_gap = second_best[0][1] - second_best[1][1] + if score_gap < 0.12: + if second_best[0][0] == 'drop' and second_best[1][0] == 'build': + if is_rising: + return 'build' + + return best_kind + + +def generate_segment_rag_summary(report: Dict[str, Any], + library_dir: Path) -> Dict[str, Any]: + """ + Genera resumen enriquecido del indexado. + + Incluye: + - Estadisticas basicas del report + - Coverage por rol + - Segmentos por archivo (avg, min, max) + - Tiempo de procesamiento estimado + - Salud del cache + """ + manifest = report.get('manifest', []) + + # Calcular estadisticas + segment_counts = [m.get('segments', 0) for m in manifest] + + # Coverage por rol + role_segments: Dict[str, int] = defaultdict(int) + for m in manifest: + for role in m.get('roles', []): + role_segments[role] += m.get('segments', 0) + + # Cache size + cache_dir = library_dir / ".segment_rag" + cache_size_bytes = sum(f.stat().st_size for f in cache_dir.glob("*.json.gz")) if cache_dir.exists() else 0 + + return { + **report, # Incluir todos los campos originales + + # Estadisticas agregadas + "summary_stats": { + "avg_segments_per_file": sum(segment_counts) / len(segment_counts) if segment_counts else 0, + "min_segments": min(segment_counts) if segment_counts else 0, + "max_segments": max(segment_counts) if segment_counts else 0, + "total_files_indexed": len(manifest), + }, + + # Coverage por rol + "role_coverage": dict(role_segments), + + # Cache info + "cache_info": { + "cache_dir": str(cache_dir), + "cache_size_bytes": cache_size_bytes, + "cache_size_mb": round(cache_size_bytes / (1024 * 1024), 2), + }, + + # Timestamp + "generated_at": time.time(), + "generated_at_iso": time.strftime('%Y-%m-%dT%H:%M:%S'), + } + + +class ReferenceAudioListener: + # Improved role patterns with more comprehensive matching + ROLE_PATTERNS = { + 'kick': ['**/*Kick*.wav', '**/*kick*.wav', '**/*KICK*.wav', '**/*Kick_*.wav', '**/*_Kick*.wav', '**/*BD*.wav', '**/*bd*.wav', '**/*bd_*.wav'], + 'snare': ['**/*Clap*Hit*.wav', '**/*Snare*.wav', '**/*snare*.wav', '**/*Clap*.wav', '**/*clap*.wav', + '**/*SNARE*.wav', '**/*CLAP*.wav', '**/*Clap_*.wav', '**/*Snare_*.wav', '**/*SD*.wav', '**/*sd*.wav'], + 'hat': ['**/*Closed Hat*.wav', '**/*Hat*.wav', '**/*hat*.wav', '**/*HAT*.wav', '**/*ClosedHat*.wav', + '**/*Open Hat*.wav', '**/*OpenHat*.wav', '**/*cym*.wav', '**/*hihat*.wav', '**/*HiHat*.wav', '**/*HH*.wav', '**/*hh_*.wav'], + 'bass_loop': ['**/*Bass Loop*.wav', '**/*Bass_Loop*.wav', '**/*bass_loop*.wav', '**/*BassLoop*.wav', + '**/*BASS LOOP*.wav', '**/*Sub*Bass*.wav', '**/*Reese*.wav', '**/*808*.wav', '**/bass/*.wav'], + 'perc_loop': ['**/*Percussion Loop*.wav', '**/*Perc_Loop*.wav', '**/*perc_loop*.wav', + '**/*PercLoop*.wav', '**/*Perc*.wav', '**/*perc*.wav', '**/*Conga*.wav', '**/perc/*.wav'], + 'top_loop': ['**/*Top Loops*.wav', '**/*Top Loop*.wav', '**/*Full Drum*.wav', '**/*top_loop*.wav', + '**/*TopLoop*.wav', '**/*Drum Loop*.wav', '**/*DrumLoop*.wav', '**/*FullDrum*.wav', '**/hat/*.wav'], + 'synth_loop': ['**/*Synth Loop*.wav', '**/*Synth_Loop*.wav', '**/*synth_loop*.wav', + '**/*SynthLoop*.wav', '**/*Synth*.wav', '**/*synth*.wav', '**/*Chord*.wav', '**/*Pad*.wav', '**/synth/*.wav'], + 'vocal_loop': ['**/*Vocal Loop*.wav', '**/*Vox*.wav', '**/*vocal_loop*.wav', '**/*VocalLoop*.wav', + '**/*Vocal*.wav', '**/*vocal*.wav', '**/*VOCAL*.wav', '**/*VoxLoop*.wav', '**/*Chopped*.wav', '**/vocal/*.wav'], + 'crash_fx': ['**/*Crash*.wav', '**/*crash*.wav', '**/*CRASH*.wav', '**/*Impact*.wav', '**/*impact*.wav', + '**/*Cymbal*.wav', '**/*cymbal*.wav', '**/fx/*.wav'], + 'fill_fx': ['**/*Fill*.wav', '**/*fill*.wav', '**/*Tom Loop*.wav', '**/*Tom*.wav', '**/*tom*.wav', + '**/*Transition*.wav', '**/*FX*.wav'], + 'snare_roll': ['**/*Snareroll*.wav', '**/*Snare Roll*.wav', '**/*snare_roll*.wav', '**/*SnareRoll*.wav', + '**/*Roll*.wav', '**/*roll*.wav', '**/*Buildup*.wav'], + 'atmos_fx': ['**/*Atmos*.wav', '**/*atmos*.wav', '**/*Drone*.wav', '**/*drone*.wav', '**/*Ambient*.wav', + '**/*Noise*.wav', '**/*noise*.wav', '**/*Texture*.wav', '**/*Pad*.wav', '**/textures/*.wav'], + 'vocal_shot': ['**/*Vocal One Shot*.wav', '**/*Vocal Importante*.wav', '**/*vocal_shot*.wav', + '**/*VocalShot*.wav', '**/*OneShot*.wav', '**/*Shot*.wav', '**/*vocal chop*.wav'], + } + + # Role bus assignments + ROLE_TO_BUS = { + 'kick': 'drums', 'snare': 'drums', 'hat': 'drums', + 'bass_loop': 'bass', + 'perc_loop': 'drums', 'top_loop': 'drums', + 'synth_loop': 'music', + 'vocal_loop': 'vocal', 'vocal_shot': 'vocal', + 'crash_fx': 'fx', 'fill_fx': 'fx', 'snare_roll': 'fx', 'atmos_fx': 'fx', + } + + # Patrones de exclusion fuerte por rol - estos NUNCA deben pasar + ROLE_EXCLUSION_PATTERNS = { + 'kick': [ + 'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix','demo', 'song', 'master', 'top loop', 'drum loop', + 'snare roll', 'fill', 'hat loop', 'vocal loop', 'complete kit','full kit', 'mixed', 'stems', 'bounce', 'preview' + ], + 'snare': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'snare roll', 'snare_roll', 'hat loop', 'kick loop', + 'top loop', 'drum loop', 'bass loop', 'complete kit', 'full kit','mixed', 'stems', 'bounce', 'preview' + ], + 'hat': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'kick loop', 'snare loop', 'bass loop', 'vocal loop', + 'complete', 'full kit', 'mixed', 'stems', 'bounce', 'preview' + ], + 'bass_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'top loop', 'vocal loop', 'vocal_loop', 'drum loop', + 'hat loop', 'snare loop', 'perc loop', 'fx loop', 'atmos','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'vocal_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'one shot', 'oneshot', 'hit', 'stab', 'drum loop', + 'bass loop', 'top loop', 'hat loop', 'kick', 'snare','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'top_loop': [ + 'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop','demo', 'song', 'master','synth loop', 'pad', 'atmos', 'riser', 'downlifter','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'fill_fx': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'loop', 'groove','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'snare_roll': [ + 'kick', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'atmos', 'pad','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'atmos_fx': [ + 'kick', 'snare', 'hat', 'clap', 'bass','full mix', 'demo', 'song', 'master', 'drum loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'synth_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'drum loop', 'vocal loop', 'bass loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'crash_fx': [ + 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'vocal_shot': [ + 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + } + + def __init__(self, library_dir: str, cache_path: Optional[str] = None): + self.library_dir = Path(library_dir) + self.cache_path = Path(cache_path) if cache_path else self.library_dir / ".reference_audio_cache.json" + self.segment_index_dir = self.library_dir / ".segment_rag" + self.segment_index_dir.mkdir(parents=True, exist_ok=True) + self._cache: Dict[str, Any] = self._load_cache() + self.device, self.device_name = self._resolve_device() + self._recent_paths = deque(maxlen=64) # Increased from 48 + self._recent_families = deque(maxlen=32) # Increased from 24 + self._family_usage_count: Dict[str, int] = {} # Track family usage for progressive penalty + self._section_detector = SectionDetector() # New section detector + self.sample_index_path = self.library_dir / ".sample_index.json" + self.vector_store_dir = self.library_dir.parent / "vector_store" + self._sample_index_by_path = self._load_sample_index_metadata() + self._vector_store_meta_by_path, self._vector_store_meta_by_name = self._load_vector_store_metadata() + + @staticmethod + def _name_contains_any(name: str, tokens: Tuple[str, ...]) -> bool: + return any(token in name for token in tokens) + + @staticmethod + def _name_contains_none(name: str, tokens: Tuple[str, ...]) -> bool: + return not any(token in name for token in tokens) + + def _resolve_device(self): + if torch is not None and torch_directml is not None: + try: + return torch_directml.device(), "directml" + except Exception: + pass + if torch is not None: + return torch.device("cpu"), "cpu" + return None, "numpy" + + def _load_cache(self) -> Dict[str, Any]: + if not self.cache_path.exists(): + return {} + try: + return json.loads(self.cache_path.read_text(encoding="utf-8")) + except Exception: + return {} + + def _save_cache(self) -> None: + try: + self.cache_path.write_text(json.dumps(self._cache, indent=2), encoding="utf-8") + except Exception: + pass + + def _cache_key(self, path: Path) -> str: + return str(path.resolve()).lower() + + def _fingerprint(self, path: Path) -> str: + stat = path.stat() + return f"{stat.st_size}:{stat.st_mtime_ns}" + + def _analysis_cache_key(self, path: Path, duration_limit: Optional[float] = None) -> str: + suffix = "full" if duration_limit is None else f"{float(duration_limit):.3f}" + return f"{self._cache_key(path)}|{suffix}" + + def _segment_index_cache_prefix(self, path: Path, windows: set) -> str: + path_key = hashlib.sha1(self._cache_key(path).encode("utf-8")).hexdigest()[:16] + fingerprint = hashlib.sha1(self._fingerprint(path).encode("utf-8")).hexdigest()[:12] + windows_key = "-".join(f"{float(item):.2f}" for item in sorted(float(value) for value in windows)) or "full" + return f"{path_key}__{fingerprint}__{windows_key}" + + def _segment_index_cache_path(self, path: Path, windows: set, duration_limit: float) -> Path: + prefix = self._segment_index_cache_prefix(path, windows) + duration_key = f"{float(duration_limit):.2f}" + return self.segment_index_dir / f"{prefix}__{duration_key}.json.gz" + + def _get_segment_rag_state_path(self) -> Path: + """Get the path to the segment RAG indexing state file.""" + return self.segment_index_dir / "indexing_state.json" + + def _save_segment_rag_state(self, state: Dict[str, Any]) -> None: + """Save segment RAG indexing state to disk.""" + state_path = self._get_segment_rag_state_path() + state_path.parent.mkdir(parents=True, exist_ok=True) + with open(state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + + def _load_segment_rag_state(self) -> Dict[str, Any]: + """Load segment RAG indexing state from disk.""" + state_path = self._get_segment_rag_state_path() + if not state_path.exists(): + return {} + try: + with open(state_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + logger.warning("Failed to load segment RAG state, starting fresh", exc_info=True) + return {} + + def _load_segment_bank_from_disk(self, path: Path, windows: set, duration_limit: float) -> List[Dict[str, Any]]: + cache_path = self._segment_index_cache_path(path, windows, duration_limit) + candidate_paths = [cache_path] + if not cache_path.exists(): + prefix = self._segment_index_cache_prefix(path, windows) + candidate_paths = sorted(self.segment_index_dir.glob(f"{prefix}__*.json.gz"), reverse=True) + if not candidate_paths: + return [] + try: + for candidate_path in candidate_paths: + with gzip.open(candidate_path, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + # Handle new format with metadata + if isinstance(payload, dict): + return payload.get("segments", []) or [] + # Handle old format (list of segments) + if isinstance(payload, list): + return payload + except Exception: + logger.debug("Failed to load segment cache for %s", path, exc_info=True) + return [] + + def _save_segment_bank_to_disk(self, path: Path, windows: set, duration_limit: float, bank: List[Dict[str, Any]], metadata: Optional[Dict[str, Any]] = None) -> None: + cache_path = self._segment_index_cache_path(path, windows, duration_limit) + try: + payload: Dict[str, Any] = {"segments": bank} + if metadata: + payload["metadata"] = { + "file_name": metadata.get("file_name") or path.name, + "path": metadata.get("path") or str(path), + "roles": metadata.get("roles") or [], + "windows": sorted(float(w) for w in windows) if windows else [], + "duration_limit": float(duration_limit), + "indexed_at": time.time(), + } + with gzip.open(cache_path, "wt", encoding="utf-8") as handle: + json.dump(payload, handle) + except Exception: + logger.debug("Failed to save segment cache for %s", path, exc_info=True) + + def _load_vector_store_metadata(self) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: + by_path: Dict[str, Dict[str, Any]] = {} + by_name: Dict[str, Dict[str, Any]] = {} + metadata_path = self.vector_store_dir / "metadata.json" + if not metadata_path.exists(): + return by_path, by_name + + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("No se pudo leer metadata del vector store: %s", exc) + return by_path, by_name + + for item in payload if isinstance(payload, list) else []: + if not isinstance(item, dict): + continue + file_name = str(item.get("filename", "") or "").strip().lower() + actual_path = self.library_dir / str(item.get("filename", "") or "") + if not actual_path.exists(): + actual_path = self.library_dir / Path(str(item.get("path", "") or "")).name + if not actual_path.exists(): + continue + normalized = str(actual_path.resolve()).lower() + normalized_item = dict(item) + normalized_item["resolved_path"] = str(actual_path) + by_path[normalized] = normalized_item + if file_name and file_name not in by_name: + by_name[file_name] = normalized_item + return by_path, by_name + + def _load_sample_index_metadata(self) -> Dict[str, Dict[str, Any]]: + if not self.sample_index_path.exists(): + return {} + + try: + payload = json.loads(self.sample_index_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("No se pudo leer sample index: %s", exc) + return {} + + entries = payload.get("samples", []) if isinstance(payload, dict) else [] + by_path: Dict[str, Dict[str, Any]] = {} + for item in entries if isinstance(entries, list) else []: + if not isinstance(item, dict): + continue + file_path = Path(str(item.get("path", "") or "")) + if not file_path.exists(): + continue + by_path[str(file_path.resolve()).lower()] = dict(item) + return by_path + + def _build_blocks(self, rms: np.ndarray, onset: np.ndarray, sr: int, + hop_length: int = 512, block_seconds: float = 8.0) -> List[Dict[str, float]]: + block_size = max(1, int(round(block_seconds * sr / hop_length))) + blocks: List[Dict[str, float]] = [] + for index in range(0, len(rms), block_size): + block_rms = rms[index:index + block_size] + block_onset = onset[index:index + block_size] + if len(block_rms) == 0: + continue + start = index * hop_length / sr + end = min(len(rms) * hop_length / sr, (index + block_size) * hop_length / sr) + blocks.append({ + "start": round(float(start), 3), + "end": round(float(end), 3), + "rms": round(float(np.mean(block_rms)), 6), + "onset": round(float(np.mean(block_onset)), 6), + "energy": round(float(np.mean(block_rms) * 0.65 + np.mean(block_onset) * 0.35), 6), + }) + return blocks + + def _vectorize_analysis(self, analysis: Dict[str, Any]) -> List[float]: + chroma = list(analysis.get("chroma", [0.0] * 12)) + return [ + float(analysis.get("tempo", 0.0)) / 180.0, + min(float(analysis.get("duration", 0.0)), 240.0) / 240.0, + float(analysis.get("rms_mean", 0.0)), + float(analysis.get("rms_std", 0.0)), + min(float(analysis.get("onset_mean", 0.0)), 8.0) / 8.0, + min(float(analysis.get("onset_std", 0.0)), 8.0) / 8.0, + min(float(analysis.get("spectral_centroid", 0.0)), 10000.0) / 10000.0, + min(float(analysis.get("spectral_rolloff", 0.0)), 14000.0) / 14000.0, + ] + chroma + + def _compute_audio_descriptor( + self, + y: np.ndarray, + sr: int, + tempo_hint: float = 0.0, + duration_hint: float = 0.0, + ) -> Dict[str, Any]: + y = np.asarray(y, dtype=np.float32) + if y.size == 0: + return { + "deep_vector": [0.0] * 53, + "harmonic_ratio": 0.5, + "percussive_ratio": 0.5, + "spectral_bandwidth": 0.0, + "spectral_bandwidth_std": 0.0, + "spectral_flatness": 0.0, + "spectral_flatness_std": 0.0, + "zero_crossing_rate": 0.0, + "zero_crossing_rate_std": 0.0, + "mfcc": [0.0] * 13, + "spectral_contrast": [0.0] * 7, + } + + if y.size < 512: + y = np.pad(y, (0, 512 - y.size)) + + hop_length = 256 if y.size < sr * 2 else 512 + n_fft = _adaptive_n_fft(len(y), default_n_fft=2048, min_n_fft=256) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + flatness = librosa.feature.spectral_flatness(y=y, n_fft=n_fft, hop_length=hop_length)[0] + zcr = librosa.feature.zero_crossing_rate(y, hop_length=hop_length)[0] + try: + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + except Exception: + chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, n_mfcc=13) + contrast = librosa.feature.spectral_contrast(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + + try: + harmonic, percussive = librosa.effects.hpss(y) + total_energy = float(np.sum(np.abs(y))) or 1.0 + harmonic_ratio = float(np.sum(np.abs(harmonic)) / total_energy) + percussive_ratio = float(np.sum(np.abs(percussive)) / total_energy) + except Exception: + harmonic_ratio = 0.5 + percussive_ratio = 0.5 + + chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) + mfcc_avg = np.mean(mfcc, axis=1) + contrast_avg = np.mean(contrast, axis=1) + duration = float(duration_hint or librosa.get_duration(y=y, sr=sr)) + + deep_vector = [ + min(float(tempo_hint or 0.0), 220.0) / 220.0, + min(duration, 240.0) / 240.0, + min(float(np.mean(rms)), 1.0), + min(float(np.std(rms)), 1.0), + min(float(np.mean(onset_env)), 8.0) / 8.0, + min(float(np.std(onset_env)), 8.0) / 8.0, + min(float(np.mean(centroid)), 12000.0) / 12000.0, + min(float(np.std(centroid)), 6000.0) / 6000.0, + min(float(np.mean(rolloff)), 16000.0) / 16000.0, + min(float(np.std(rolloff)), 8000.0) / 8000.0, + min(float(np.mean(bandwidth)), 8000.0) / 8000.0, + min(float(np.std(bandwidth)), 4000.0) / 4000.0, + min(float(np.mean(flatness)), 1.0), + min(float(np.std(flatness)), 1.0), + min(float(np.mean(zcr)), 1.0), + min(float(np.std(zcr)), 1.0), + min(max(harmonic_ratio, 0.0), 1.0), + min(max(percussive_ratio, 0.0), 1.0), + ] + [float(item) for item in chroma_avg.tolist()] \ + + [float(np.clip(item / 100.0, -1.0, 1.0)) for item in mfcc_avg.tolist()] \ + + [min(float(item), 80.0) / 80.0 for item in contrast_avg.tolist()] + + return { + "deep_vector": [round(float(item), 6) for item in deep_vector], + "harmonic_ratio": round(float(harmonic_ratio), 6), + "percussive_ratio": round(float(percussive_ratio), 6), + "spectral_bandwidth": round(float(np.mean(bandwidth)), 3), + "spectral_bandwidth_std": round(float(np.std(bandwidth)), 3), + "spectral_flatness": round(float(np.mean(flatness)), 6), + "spectral_flatness_std": round(float(np.std(flatness)), 6), + "zero_crossing_rate": round(float(np.mean(zcr)), 6), + "zero_crossing_rate_std": round(float(np.std(zcr)), 6), + "mfcc": [round(float(item), 6) for item in mfcc_avg.tolist()], + "spectral_contrast": [round(float(item), 6) for item in contrast_avg.tolist()], + } + + def _section_kind_at_time(self, sections: List[Dict[str, Any]], seconds: float) -> str: + for section in sections: + start = float(section.get("start", 0.0) or 0.0) + end = float(section.get("end", start) or start) + if start <= seconds < end: + return str(section.get("kind", "verse") or "verse").lower() + return str(sections[-1].get("kind", "verse") if sections else "verse").lower() + + def _build_reference_segment_bank( + self, + reference_path: str, + reference: Dict[str, Any], + sections: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + path = Path(reference_path) + fingerprint = self._fingerprint(path) + cache_key = f"segments::{self._cache_key(path)}::{fingerprint}" + cached = self._cache.get(cache_key) + if isinstance(cached, list) and cached: + return cached + + y, sr = librosa.load(str(path), sr=22050, mono=True) + duration = float(librosa.get_duration(y=y, sr=sr)) + tempo = float(reference.get("tempo", 0.0) or 0.0) + bank: List[Dict[str, Any]] = [] + + for window_seconds in (1.0, 2.0, 4.0, 8.0): + hop_seconds = max(0.25, window_seconds / 2.0) + cursor = 0.0 + while cursor + 0.25 <= duration: + end = min(duration, cursor + window_seconds) + start_sample = int(cursor * sr) + end_sample = max(start_sample + 256, int(end * sr)) + segment_audio = y[start_sample:end_sample] + if segment_audio.size < 256: + cursor += hop_seconds + continue + descriptor = self._compute_audio_descriptor( + segment_audio, + sr, + tempo_hint=tempo, + duration_hint=end - cursor, + ) + midpoint = cursor + ((end - cursor) / 2.0) + bank.append({ + "start": round(float(cursor), 3), + "end": round(float(end), 3), + "window_seconds": round(float(end - cursor), 3), + "kind": self._section_kind_at_time(sections, midpoint), + "vector": descriptor.get("deep_vector", []), + "rms_mean": descriptor.get("deep_vector", [0.0, 0.0, 0.0])[2] if descriptor.get("deep_vector") else 0.0, + "onset_mean": descriptor.get("deep_vector", [0.0] * 5)[4] if descriptor.get("deep_vector") else 0.0, + "spectral_centroid": round(float(descriptor.get("deep_vector", [0.0] * 7)[6] * 12000.0), 3) if descriptor.get("deep_vector") else 0.0, + "spectral_rolloff": round(float(descriptor.get("deep_vector", [0.0] * 9)[8] * 16000.0), 3) if descriptor.get("deep_vector") else 0.0, + "harmonic_ratio": descriptor.get("harmonic_ratio", 0.5), + "percussive_ratio": descriptor.get("percussive_ratio", 0.5), + "spectral_flatness": descriptor.get("spectral_flatness", 0.0), + "zero_crossing_rate": descriptor.get("zero_crossing_rate", 0.0), + }) + cursor += hop_seconds + + self._cache[cache_key] = bank + self._save_cache() + return bank + + def _build_candidate_segment_bank( + self, + candidate_path: str, + windows: set, + duration_limit: float = 32.0, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + path = Path(candidate_path) + if not path.exists(): + return [] + + fingerprint = self._fingerprint(path) + windows_key = ",".join(str(item) for item in sorted(float(value) for value in windows)) or "full" + cache_key = f"candidate_segments::{self._cache_key(path)}::{fingerprint}::{windows_key}::{float(duration_limit):.3f}" + cached = self._cache.get(cache_key) + if isinstance(cached, list) and cached: + return cached + disk_cached = self._load_segment_bank_from_disk(path, windows, duration_limit) + if disk_cached: + self._cache[cache_key] = disk_cached + return disk_cached + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) + + file_duration = float(librosa.get_duration(y=y, sr=sr)) + bank: List[Dict[str, Any]] = [] + + for window_seconds in sorted(float(value) for value in windows if float(value) > 0.0): + if file_duration <= 0.0: + continue + hop_seconds = max(0.25, window_seconds / 2.0) + cursor = 0.0 + while cursor + 0.25 <= file_duration: + end = min(file_duration, cursor + window_seconds) + start_sample = int(cursor * sr) + end_sample = max(start_sample + 256, int(end * sr)) + segment_audio = y[start_sample:end_sample] + if segment_audio.size < 256: + cursor += hop_seconds + continue + descriptor = self._compute_audio_descriptor( + segment_audio, + sr, + duration_hint=end - cursor, + ) + bank.append({ + "start": round(float(cursor), 3), + "end": round(float(end), 3), + "window_seconds": round(float(end - cursor), 3), + "vector": descriptor.get("deep_vector", []), + }) + cursor += hop_seconds + + self._cache[cache_key] = bank + self._save_segment_bank_to_disk(path, windows, duration_limit, bank, metadata=metadata) + self._save_cache() + return bank + + def analyze_file(self, file_path: str, duration_limit: Optional[float] = None) -> Dict[str, Any]: + if librosa is None: + raise RuntimeError("librosa no está disponible") + + path = Path(file_path) + cache_key = self._analysis_cache_key(path, duration_limit) + legacy_key = self._cache_key(path) + fingerprint = self._fingerprint(path) + cached = self._cache.get(cache_key) + if not isinstance(cached, dict) and duration_limit is None: + cached = self._cache.get(legacy_key) + if isinstance(cached, dict) and cached.get("fingerprint") == fingerprint: + return dict(cached["analysis"]) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) + hop_length = 512 + n_fft = _adaptive_n_fft(len(y)) + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] + rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft)[0] + try: + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + except Exception: + chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) + key, key_score = _detect_key(chroma_avg) + tempo = _safe_float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median)) + + analysis = { + "path": str(path), + "file_name": path.name, + "duration": round(float(librosa.get_duration(y=y, sr=sr)), 3), + "tempo": round(float(tempo), 3), + "key": key, + "key_confidence": round(float(key_score), 6), + "rms_mean": round(float(np.mean(rms)), 6), + "rms_std": round(float(np.std(rms)), 6), + "onset_mean": round(float(np.mean(onset_env)), 6), + "onset_std": round(float(np.std(onset_env)), 6), + "spectral_centroid": round(float(np.mean(centroid)), 3), + "spectral_rolloff": round(float(np.mean(rolloff)), 3), + "chroma": [round(float(item), 6) for item in chroma_avg.tolist()], + "blocks": self._build_blocks(rms, onset_env, sr, hop_length=hop_length), + } + analysis["vector"] = self._vectorize_analysis(analysis) + analysis.update(self._compute_audio_descriptor( + y, + sr, + tempo_hint=float(analysis.get("tempo", 0.0) or 0.0), + duration_hint=float(analysis.get("duration", 0.0) or 0.0), + )) + + self._cache[cache_key] = { + "fingerprint": fingerprint, + "analysis": analysis, + } + if duration_limit is None: + self._cache[legacy_key] = self._cache[cache_key] + self._save_cache() + return dict(analysis) + + def analyze_reference(self, reference_path: str) -> Dict[str, Any]: + analysis = self.analyze_file(reference_path) + energies = [float(block.get("energy", 0.0)) for block in analysis.get("blocks", [])] + if energies: + max_energy = max(energies) or 1.0 + for block in analysis["blocks"]: + block["energy_norm"] = round(float(block["energy"]) / max_energy, 6) + analysis["device"] = self.device_name + return analysis + + def _is_excluded_full_track(self, path: Path, sample_meta: Optional[Dict[str, Any]], vector_meta: Optional[Dict[str, Any]]) -> bool: + vector_type = str((vector_meta or {}).get("type", "") or "").lower() + if vector_type == "full_track": + return True + + duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) + category = str((sample_meta or {}).get("category", "") or "").lower() + if path.suffix.lower() == ".mp3" and duration_estimate >= 45.0: + return True + + if path.suffix.lower() == ".mp3" and path.exists(): + size_bytes = int((vector_meta or {}).get("size_bytes", (sample_meta or {}).get("size", 0)) or 0) + if size_bytes >= 5_000_000 and category in {"unknown", "loop"}: + return True + return False + + def _duration_estimate( + self, + path: Path, + sample_meta: Optional[Dict[str, Any]], + vector_meta: Optional[Dict[str, Any]], + ) -> float: + duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) + if duration_estimate > 0.0: + return duration_estimate + sample_duration = float((sample_meta or {}).get("duration", 0.0) or 0.0) + if sample_duration > 0.0: + return sample_duration + sample_size = int((sample_meta or {}).get("size", 0) or 0) + if sample_size > 0: + return min(32.0, max(0.1, sample_size / 176400.0)) + try: + return min(32.0, max(0.1, path.stat().st_size / 176400.0)) + except Exception: + return 0.0 + + def _catalog_role_match( + self, + role: str, + path: Path, + sample_meta: Optional[Dict[str, Any]], + vector_meta: Optional[Dict[str, Any]], + ) -> bool: + if self._is_excluded_full_track(path, sample_meta, vector_meta): + return False + + name = path.name + stem = path.stem.lower() + name_match = self._matches_role_name(role, name) + + category = str((sample_meta or {}).get("category", "") or "").lower() + vector_type = str((vector_meta or {}).get("type", "") or "").lower() + duration_estimate = self._duration_estimate(path, sample_meta, vector_meta) + + role_categories = { + "kick": {"kick"}, + "snare": {"snare", "clap"}, + "hat": {"hat"}, + "bass_loop": {"bass"}, + "perc_loop": {"perc"}, + "top_loop": {"loop"}, + "synth_loop": {"synth"}, + "vocal_loop": {"vocal"}, + "crash_fx": {"fx"}, + "fill_fx": {"fx"}, + "snare_roll": {"fx"}, + "atmos_fx": {"fx", "synth"}, + "vocal_shot": {"vocal"}, + } + role_types = ROLE_VECTOR_TYPES.get(role, set()) + min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role, (0.0, 999.0)) + duration_ok = duration_estimate <= 0.0 or (min_dur <= duration_estimate <= max_dur) + loopish_name = self._name_contains_any(stem, ("loop", "groove", "full drum", "full mix", "drum loop", "top loop")) + + if name_match: + return duration_ok + if role in {"kick", "snare", "hat"}: + return bool(category and category in role_categories.get(role, set()) and duration_ok) + if role == "bass_loop": + if category == "bass" and duration_ok: + return True + if vector_type and vector_type in role_types and duration_ok and self._name_contains_none(stem, ("drum loop", "full mix", "top loop", "vocal")): + return True + return False + if role == "perc_loop": + if category == "perc" and duration_ok and loopish_name: + return True + if vector_type and vector_type in role_types and duration_ok and loopish_name: + return True + return False + if role == "top_loop": + if category == "loop" and duration_ok and loopish_name and self._name_contains_none(stem, ("bass loop", "vocal", "synth loop")): + return True + if vector_type and vector_type in role_types and duration_ok and loopish_name: + return True + return False + if role == "synth_loop": + synthish_name = self._name_contains_any(stem, ("synth", "lead", "hook", "pluck", "pad", "chord", "arp", "melod")) + if category == "synth" and duration_ok and synthish_name: + return True + if vector_type and vector_type in role_types and duration_ok and synthish_name: + return True + return False + if role == "vocal_loop": + vocalish_loop = self._name_contains_any(stem, ("vocal loop", "vox", "acapella", "chant", "phrase", "vocal")) + if category == "vocal" and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): + return True + if vector_type and vector_type in role_types and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): + return True + return False + if role == "crash_fx": + return False + if role == "fill_fx": + if category == "fx" and duration_ok and self._name_contains_any(stem, ("fill", "transition", "tom loop", "drum fill", "break fill")): + return True + return False + if role == "snare_roll": + if category == "fx" and duration_ok and self._name_contains_any(stem, ("snareroll", "snare roll", "roll", "buildup")): + return True + return False + if role == "atmos_fx": + atmosish_name = self._name_contains_any(stem, ("atmos", "drone", "ambient", "noise", "texture", "downfilter", "sweep", "wash")) + if category in role_categories.get(role, set()) and duration_ok and atmosish_name: + return True + if vector_type and vector_type in role_types and duration_ok and atmosish_name: + return True + return False + if role == "vocal_shot": + if category == "vocal" and duration_ok and duration_estimate <= 3.0: + return True + if vector_type in role_types and duration_ok and duration_estimate <= 3.0: + return True + return False + return False + + def prewarm_library_matching_cache( + self, + roles: Optional[List[str]] = None, + max_files: Optional[int] = None, + duration_limit: float = 32.0, + ) -> Dict[str, Any]: + target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] + assets = self._list_assets() + windows_by_role = { + role: set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + for role in target_roles + } + + files: Dict[str, Tuple[Path, set]] = {} + for role in target_roles: + for file_path in assets.get(role, []): + normalized = str(file_path.resolve()).lower() + if normalized not in files: + files[normalized] = (file_path, set()) + files[normalized][1].update(windows_by_role.get(role, set())) + + ordered_files = list(files.values()) + if max_files is not None: + ordered_files = ordered_files[: max(0, int(max_files))] + + analyzed = 0 + segmented = 0 + errors = 0 + for file_path, windows in ordered_files: + try: + analysis = self.analyze_file(str(file_path), duration_limit=duration_limit) + analyzed += 1 + if float(analysis.get("duration", 0.0) or 0.0) > max(windows or {4.0}) * 1.5: + self._build_candidate_segment_bank(str(file_path), windows or {4.0}, duration_limit=duration_limit) + segmented += 1 + except Exception: + errors += 1 + + return { + "roles": target_roles, + "files_considered": len(ordered_files), + "analyzed": analyzed, + "segmented": segmented, + "errors": errors, + "cache_path": str(self.cache_path), + "device": self.device_name, + } + + def build_segment_rag_index( + self, + roles: Optional[List[str]] = None, + max_files: Optional[int] = None, + duration_limit: float = 32.0, + force: bool = False, + offset: int = 0, + batch_size: Optional[int] = None, + resume: bool = False, + ) -> Dict[str, Any]: + target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] + assets = self._list_assets() + files: Dict[str, Dict[str, Any]] = {} + + for role in target_roles: + for file_path in assets.get(role, []): + normalized = str(file_path.resolve()).lower() + if normalized not in files: + files[normalized] = { + "path": file_path, + "roles": set(), + "windows": set(), + } + files[normalized]["roles"].add(role) + files[normalized]["windows"].update(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + + ordered_files = sorted( + files.values(), + key=lambda item: ( + -len(item["roles"]), + -sum(float(value) for value in item["windows"]), + item["path"].name.lower(), + ), + ) + + state = self._load_segment_rag_state() + indexed_entries = dict(state.get("indexed_entries", {}) or {}) + if resume: + indexed_paths = set(state.get("indexed_paths", []) or []) + if indexed_paths: + before_resume = len(ordered_files) + ordered_files = [ + entry for entry in ordered_files + if str(entry["path"].resolve()).lower() not in indexed_paths + ] + logger.info( + "Resume mode: skipped %d already indexed files, %d remaining", + before_resume - len(ordered_files), + len(ordered_files), + ) + + total_available = len(ordered_files) + if offset > 0: + ordered_files = ordered_files[offset:] + + limit = batch_size if batch_size is not None else max_files + if limit is not None: + ordered_files = ordered_files[: max(0, int(limit))] + + files_remaining = max(0, total_available - offset - len(ordered_files)) + + built = 0 + reused = 0 + skipped = 0 + errors = 0 + total_segments = 0 + manifest: List[Dict[str, Any]] = [] + + for entry in ordered_files: + path = entry["path"] + windows = entry["windows"] or {4.0} + normalized = str(path.resolve()).lower() + sample_meta = self._sample_index_by_path.get(normalized) + vector_meta = self._vector_store_meta_by_path.get(normalized) + estimated_duration = self._duration_estimate(path, sample_meta, vector_meta) + effective_duration_limit = min(max(estimated_duration, 0.5), duration_limit) if estimated_duration > 0.0 else duration_limit + disk_cached = self._load_segment_bank_from_disk(path, windows, effective_duration_limit) + cache_prefix = self._segment_index_cache_prefix(path, windows) + if disk_cached and not force: + reused += 1 + total_segments += len(disk_cached) + entry_report = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + "segments": len(disk_cached), + "cached": True, + "cache_prefix": cache_prefix, + } + manifest.append(entry_report) + indexed_entries[normalized] = entry_report + continue + try: + analysis = self.analyze_file(str(path), duration_limit=duration_limit) + duration = float(analysis.get("duration", 0.0) or 0.0) + if duration < 0.5: + skipped += 1 + continue + segment_metadata = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + } + bank = self._build_candidate_segment_bank(str(path), windows, duration_limit=min(max(duration, 0.5), duration_limit), metadata=segment_metadata) + built += 1 + total_segments += len(bank) + entry_report = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + "segments": len(bank), + "cached": False, + "cache_prefix": cache_prefix, + } + manifest.append(entry_report) + indexed_entries[normalized] = entry_report + + if (built + reused) % 10 == 0: + periodic_state = { + "indexed_files": [item["file_name"] for item in indexed_entries.values()], + "indexed_paths": list(indexed_entries.keys()), + "indexed_entries": indexed_entries, + "last_offset": offset + (built + reused), + "total_processed": len(indexed_entries), + "timestamp": time.time(), + } + self._save_segment_rag_state(periodic_state) + logger.debug("Saved segment RAG state after %d processed files", built + reused) + except Exception: + errors += 1 + logger.debug("Failed to build segment index for %s", path, exc_info=True) + + final_state = { + "indexed_files": [item["file_name"] for item in indexed_entries.values()], + "indexed_paths": list(indexed_entries.keys()), + "indexed_entries": indexed_entries, + "last_offset": offset + (built + reused), + "total_processed": len(indexed_entries), + "timestamp": time.time(), + "complete": files_remaining == 0, + } + self._save_segment_rag_state(final_state) + + return { + "roles": target_roles, + "files_targeted": len(ordered_files), + "total_available": total_available, + "built": built, + "reused": reused, + "skipped": skipped, + "errors": errors, + "total_segments": total_segments, + "segment_index_dir": str(self.segment_index_dir), + "device": self.device_name, + "manifest": manifest, + "offset": offset, + "batch_size": batch_size, + "files_remaining": files_remaining, + "resumed": resume, + } + + def _list_assets(self) -> Dict[str, List[Path]]: + assets: Dict[str, List[Path]] = {role: [] for role in self.ROLE_PATTERNS} + if not self.library_dir.exists(): + return assets + + for role, patterns in self.ROLE_PATTERNS.items(): + seen = set() + indexed_paths = set(self._sample_index_by_path.keys()) | set(self._vector_store_meta_by_path.keys()) + + for normalized_path in sorted(indexed_paths): + path = Path(normalized_path) + if not path.exists() or not path.is_file(): + continue + if path.suffix.lower() not in {'.wav', '.aif', '.aiff', '.mp3'}: + continue + sample_meta = self._sample_index_by_path.get(normalized_path) + vector_meta = self._vector_store_meta_by_path.get(normalized_path) + if not self._catalog_role_match(role, path, sample_meta, vector_meta): + continue + if normalized_path in seen: + continue + seen.add(normalized_path) + assets[role].append(path) + + for pattern in patterns: + for match in sorted(self.library_dir.glob(pattern)): + if match.is_file() and match.suffix.lower() in {'.wav', '.aif', '.aiff', '.mp3'}: + normalized_match = str(match.resolve()).lower() + sample_meta = self._sample_index_by_path.get(normalized_match) + vector_meta = self._vector_store_meta_by_path.get(normalized_match) + if not self._catalog_role_match(role, match, sample_meta, vector_meta): + continue + if normalized_match in seen: + continue + seen.add(normalized_match) + assets[role].append(match) + return assets + + def _apply_role_exclusions(self, role: str, file_name: str) -> Tuple[bool, str]: + """ + Aplica exclusiones fuertes por rol. + + Retorna: + (should_reject, reason) - True si debe rechazar, False si pasa + """ + role_lower = role.lower() + if role_lower not in self.ROLE_EXCLUSION_PATTERNS: + return False, "" + + name_lower = file_name.lower() + exclusions = self.ROLE_EXCLUSION_PATTERNS[role_lower] + + for excl in exclusions: + if excl in name_lower: + return True, f"excluded pattern '{excl}' for role '{role}'" + + return False, "" + + def _validate_role_requirement(self, role: str, item: Dict[str, Any]) -> Tuple[bool, float, str]: + """ + Validates that a candidate sample meets role requirements. + + Returns: + (passes, score_modifier, reason) - True if passes, score modifier (0-1), reason string + """ + role_lower = role.lower() + file_name = str(item.get("file_name", "") or "").lower() + duration = float(item.get("duration", 0.0) or 0.0) + + min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role_lower, (0.0, 999.0)) + + if duration > 0.0 and not (min_dur <= duration <= max_dur): + return False, 0.0, f"duration {duration:.1f}s outside range [{min_dur}, {max_dur}] for role {role}" + + if role_lower in {'kick', 'snare', 'hat', 'clap', 'hat_closed', 'hat_open'}: + if 'loop' in file_name and 'full' not in file_name: + if duration > 4.0: + return False, 0.3, f"one-shot role {role} has loop-like file (duration={duration:.1f}s)" + + if role_lower in {'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop'}: + if duration < 1.0: + return False, 0.2, f"loop role {role} has very short duration ({duration:.1f}s)" + + must_contain = { + 'kick': ['kick', 'bd', 'bass_drum', '808'], + 'snare': ['snare', 'snr', 'sd', 'rim'], + 'clap': ['clap', 'clp', 'hand'], + 'hat': ['hat', 'hh', 'hihat', 'cymbal'], + 'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline'], + 'vocal_loop': ['vocal', 'vox', 'voice', 'chant', 'acapella'], + 'top_loop': ['top', 'perc', 'drum', 'full'], + 'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck'], + 'crash_fx': ['crash', 'cymbal', 'impact', 'ride'], + 'fill_fx': ['fill', 'transition', 'tom'], + 'snare_roll': ['roll', 'snare', 'build'], + 'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise'], + 'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab'], + } + + if role_lower in must_contain: + found = any(kw in file_name for kw in must_contain[role_lower]) + if not found: + return True, 0.65, f"no role keyword for {role}" + + return True, 1.0, "passes role validation" + + def _matches_role_name(self, role: str, file_name: str) -> bool: + name = Path(file_name).stem.lower() + # Check exclusions first + should_reject, reason = self._apply_role_exclusions(role, name) + if should_reject: + logger.debug("ROLE_EXCLUSION: %s", reason) + return False + if role == 'kick': + return 'kick' in name and 'loop' not in name + if role == 'snare': + return ('snare' in name or 'clap' in name) and 'roll' not in name and 'loop' not in name + if role == 'hat': + return 'hat' in name and 'loop' not in name and 'full mix' not in name + if role == 'bass_loop': + return self._name_contains_any(name, ('bass loop', 'bass_loop', 'bassline', 'sub bass', 'sub_bass', 'reese', '808')) \ + and self._name_contains_none(name, ('drum loop', 'full mix', 'top loop', 'vocal')) + if role == 'perc_loop': + return ( + self._name_contains_any(name, ('perc loop', 'perc_loop', 'percussion loop', 'drum loop', 'drum_loop', 'groove')) + or ('perc' in name and 'loop' in name) + or (self._name_contains_any(name, ('shaker', 'bongo', 'conga', 'timbale')) and 'loop' in name) + ) and self._name_contains_none(name, ('full mix', 'one shot', 'shot', 'vocal')) + if role == 'top_loop': + return self._name_contains_any(name, ('top loop', 'top_loop', 'top loops', 'full drum', 'full mix', 'drum loop', 'drum_loop')) \ + and self._name_contains_none(name, ('bass loop', 'vocal', 'synth loop')) + if role == 'synth_loop': + return ( + self._name_contains_any(name, ('synth loop', 'synth_loop', 'lead loop', 'lead_loop', 'hook', 'melody loop', 'melodic loop')) + or ('synth' in name and 'loop' in name) + or (self._name_contains_any(name, ('chord', 'pad', 'pluck', 'arp')) and 'loop' in name) + ) and self._name_contains_none(name, ('drum loop', 'full mix', 'vocal')) + if role == 'vocal_loop': + return ( + self._name_contains_any(name, ('vocal loop', 'vox loop', 'vox_', 'acapella', 'chant loop')) + or ('vocal' in name and 'loop' in name) + ) and self._name_contains_none(name, ('one shot', 'shot', 'importante', 'stab', 'hit')) + if role == 'crash_fx': + return self._name_contains_any(name, ('crash', 'cymbal', 'riser', 'downlifter', 'sweep', 'uplifter')) or ' impact ' in f" {name} " + if role == 'fill_fx': + return self._name_contains_any(name, ('fill', 'transition', 'tom loop', 'drum fill', 'break fill')) + if role == 'snare_roll': + return self._name_contains_any(name, ('snareroll', 'snare roll', 'roll', 'buildup')) and 'one shot' not in name + if role == 'atmos_fx': + return self._name_contains_any(name, ('atmos', 'drone', 'ambient', 'noise', 'texture', 'downfilter', 'wash', 'sweep')) + if role == 'vocal_shot': + return self._name_contains_any(name, ('vocal one shot', 'one shot', 'shot', 'importante', 'vocal chop', 'vocal stab')) + return True + + def _cosine_scores(self, reference_vector: List[float], candidate_vectors: List[List[float]]) -> List[float]: + if not candidate_vectors: + return [] + + ref = np.asarray(reference_vector, dtype=np.float32) + candidates = np.asarray(candidate_vectors, dtype=np.float32) + if torch is None or self.device is None or F is None: + ref_norm = np.linalg.norm(ref) or 1.0 + cand_norm = np.linalg.norm(candidates, axis=1) + cand_norm[cand_norm == 0] = 1.0 + return (candidates @ ref / (cand_norm * ref_norm)).astype(float).tolist() + + ref_tensor = torch.tensor(ref, dtype=torch.float32, device=self.device) + candidate_tensor = torch.tensor(candidates, dtype=torch.float32, device=self.device) + scores = F.cosine_similarity(candidate_tensor, ref_tensor.unsqueeze(0), dim=1) + return scores.detach().cpu().numpy().astype(float).tolist() + + def _cosine_matrix(self, left_vectors: List[List[float]], right_vectors: List[List[float]]) -> np.ndarray: + if not left_vectors or not right_vectors: + return np.zeros((0, 0), dtype=np.float32) + + left = np.asarray(left_vectors, dtype=np.float32) + right = np.asarray(right_vectors, dtype=np.float32) + + if torch is None or self.device is None or F is None: + left_norm = np.linalg.norm(left, axis=1, keepdims=True) + right_norm = np.linalg.norm(right, axis=1, keepdims=True) + left_norm[left_norm == 0] = 1.0 + right_norm[right_norm == 0] = 1.0 + return (left / left_norm) @ (right / right_norm).T + + left_tensor = torch.tensor(left, dtype=torch.float32, device=self.device) + right_tensor = torch.tensor(right, dtype=torch.float32, device=self.device) + left_tensor = F.normalize(left_tensor, p=2, dim=1) + right_tensor = F.normalize(right_tensor, p=2, dim=1) + return (left_tensor @ right_tensor.T).detach().cpu().numpy().astype(np.float32) + + def _tempo_score(self, candidate_tempo: float, reference_tempo: float) -> float: + if candidate_tempo <= 0 or reference_tempo <= 0: + return 0.5 + variants = [ + candidate_tempo, + candidate_tempo * 2.0, + candidate_tempo / 2.0, + candidate_tempo * 4.0, + candidate_tempo / 4.0, + ] + diff = min(abs(item - reference_tempo) for item in variants) + return math.exp(-diff / 10.0) + + def _vector_store_entry(self, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]: + path_key = str(candidate.get("path", "") or "").strip().lower() + if path_key and path_key in self._vector_store_meta_by_path: + return self._vector_store_meta_by_path[path_key] + file_name = str(candidate.get("file_name", "") or Path(path_key).name).strip().lower() + if file_name and file_name in self._vector_store_meta_by_name: + return self._vector_store_meta_by_name[file_name] + return None + + def _role_segment_relevance(self, role: str, segment: Dict[str, Any], reference: Dict[str, Any]) -> float: + kind = str(segment.get("kind", "verse") or "verse").lower() + centroid = float(segment.get("spectral_centroid", 0.0) or 0.0) + onset = float(segment.get("onset_mean", 0.0) or 0.0) + harmonic = float(segment.get("harmonic_ratio", 0.5) or 0.5) + percussive = float(segment.get("percussive_ratio", 0.5) or 0.5) + flatness = float(segment.get("spectral_flatness", 0.0) or 0.0) + zcr = float(segment.get("zero_crossing_rate", 0.0) or 0.0) + rms = float(segment.get("rms_mean", 0.5) or 0.5) + score = 0.0 + + if role == 'kick': + transient = min(1.0, onset / 3.0) + low_centroid = max(0.0, 1.0 - (centroid / 3000.0)) + score = transient * 0.35 + percussive * 0.30 + low_centroid * 0.20 + rms * 0.15 + elif role == 'snare': + transient = min(1.0, onset / 4.5) + mid_centroid = min(1.0, max(0.0, (centroid - 800) / 4000.0)) + score = transient * 0.32 + percussive * 0.28 + mid_centroid * 0.25 + elif role == 'hat': + high_centroid = min(1.0, centroid / 10000.0) + transient = min(1.0, onset / 4.0) + score = high_centroid * 0.38 + transient * 0.32 + zcr * 0.15 + percussive * 0.15 + elif role == 'bass_loop': + low_centroid = max(0.0, 1.0 - (centroid / 2200.0)) + harmonic_content = harmonic * 0.35 + low_flat = max(0.0, 1.0 - flatness * 1.5) + score = harmonic_content + low_centroid * 0.30 + low_flat * 0.20 + rms * 0.15 + elif role in {'perc_loop', 'top_loop'}: + transient = min(1.0, onset / 4.0) + mid_high_centroid = min(1.0, max(0.0, centroid / 8500.0)) + score = transient * 0.35 + percussive * 0.30 + mid_high_centroid * 0.20 + rms * 0.15 + elif role == 'synth_loop': + harmonic_content = harmonic * 0.38 + mid_centroid = min(1.0, max(0.0, (centroid - 500) / 7000.0)) + low_flat = max(0.0, 1.0 - flatness * 1.2) + score = harmonic_content + mid_centroid * 0.22 + low_flat * 0.25 + elif role == 'vocal_loop': + harmonic_content = harmonic * 0.32 + mid_centroid = min(1.0, max(0.0, (centroid - 200) / 4000.0)) + low_flat = max(0.0, 1.0 - flatness * 1.5) + score = harmonic_content + mid_centroid * 0.18 + low_flat * 0.25 + rms * 0.25 + elif role == 'crash_fx': + high_centroid = min(1.0, centroid / 12000.0) + transient = min(1.0, onset / 3.5) + high_flat = min(1.0, flatness * 2.5) + score = high_centroid * 0.30 + transient * 0.25 + high_flat * 0.25 + elif role == 'fill_fx': + transient = min(1.0, onset / 4.0) + percussive_content = percussive * 0.35 + mid_centroid = min(1.0, max(0.0, centroid / 7000.0)) + score = transient * 0.30 + percussive_content + mid_centroid * 0.20 + elif role == 'snare_roll': + transient = min(1.0, onset / 4.5) + percussive_content = percussive * 0.38 + mid_centroid = min(1.0, max(0.0, (centroid - 1000) / 5000.0)) + score = transient * 0.35 + percussive_content + mid_centroid * 0.15 + elif role == 'atmos_fx': + harmonic_content = harmonic * 0.28 + low_onset = max(0.0, 1.0 - onset * 2.0) + high_flat = min(1.0, flatness * 2.0) + score = harmonic_content + low_onset * 0.22 + high_flat * 0.25 + rms * 0.25 + elif role == 'vocal_shot': + harmonic_content = harmonic * 0.30 + transient = min(1.0, onset / 4.0) + mid_centroid = min(1.0, max(0.0, (centroid - 300) / 4500.0)) + score = harmonic_content + transient * 0.22 + mid_centroid * 0.28 + + section_bonus_map = { + 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': 0.02}, + 'snare': {'intro': -0.06, 'verse': 0.06, 'build': 0.10, 'drop': 0.14, 'break': 0.03, 'outro': -0.04}, + 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.14, 'drop': 0.12, 'break': -0.04, 'outro': 0.02}, + 'bass_loop': {'intro': -0.12, 'verse': 0.06, 'build': 0.12, 'drop': 0.20, 'break': -0.10, 'outro': -0.06}, + 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.18, 'break': 0.06, 'outro': 0.00}, + 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.16, 'drop': 0.18, 'break': 0.02, 'outro': 0.00}, + 'synth_loop': {'intro': 0.06, 'verse': 0.04, 'build': 0.14, 'drop': 0.20, 'break': 0.12, 'outro': 0.02}, + 'vocal_loop': {'intro': -0.06, 'verse': 0.14, 'build': 0.08, 'drop': 0.16, 'break': 0.10, 'outro': -0.02}, + 'crash_fx': {'intro': 0.10, 'verse': 0.02, 'build': 0.16, 'drop': 0.10, 'break': -0.06, 'outro': 0.10}, + 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.20, 'drop': 0.12, 'break': 0.10, 'outro': 0.02}, + 'snare_roll': {'intro': -0.08, 'verse': 0.02, 'build': 0.26, 'drop': 0.14, 'break': 0.06, 'outro': -0.10}, + 'atmos_fx': {'intro': 0.22, 'verse': 0.04, 'build': 0.02, 'drop': -0.06, 'break': 0.24, 'outro': 0.18}, + 'vocal_shot': {'intro': -0.06, 'verse': 0.10, 'build': 0.12, 'drop': 0.16, 'break': 0.08, 'outro': -0.04}, + } + score += section_bonus_map.get(role, {}).get(kind, 0.0) + return max(0.0, min(1.0, score)) + + def _select_role_reference_segments( + self, + role: str, + reference: Dict[str, Any], + segment_bank: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + if not segment_bank: + return [] + settings = ROLE_SEGMENT_SETTINGS.get(role, {}) + allowed_windows = settings.get("windows", set()) + allowed_kinds = settings.get("section_kinds", set()) + filtered = [ + segment for segment in segment_bank + if (not allowed_windows or round(float(segment.get("window_seconds", 0.0)), 1) in allowed_windows) + and (not allowed_kinds or str(segment.get("kind", "")).lower() in allowed_kinds) + ] + if not filtered: + filtered = segment_bank + ranked = sorted( + filtered, + key=lambda item: self._role_segment_relevance(role, item, reference), + reverse=True, + ) + return ranked[:int(settings.get("top_k", 6) or 6)] + + def _role_segment_similarity( + self, + role: str, + candidate: Dict[str, Any], + role_segments: List[Dict[str, Any]], + ) -> float: + role_vectors = [list(segment.get("vector", []) or []) for segment in role_segments if segment.get("vector")] + if not role_vectors: + return 0.0 + + candidate_vectors: List[List[float]] = [] + candidate_vector = list(candidate.get("deep_vector", []) or []) + if candidate_vector: + candidate_vectors.append(candidate_vector) + + candidate_path = str(candidate.get("path", "") or "") + candidate_duration = float(candidate.get("duration", 0.0) or 0.0) + windows = set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + if candidate_path and candidate_duration > max(windows or {4.0}) * 1.5: + segment_bank = self._build_candidate_segment_bank(candidate_path, windows, duration_limit=min(max(candidate_duration, 0.0), 32.0)) + candidate_vectors.extend( + list(segment.get("vector", []) or []) + for segment in segment_bank + if segment.get("vector") + ) + + if not candidate_vectors: + return 0.0 + + matrix = self._cosine_matrix(candidate_vectors, role_vectors) + if matrix.size == 0: + return 0.0 + best_per_candidate = matrix.max(axis=1).tolist() + best_per_candidate.sort(reverse=True) + top = best_per_candidate[: min(3, len(best_per_candidate))] + return float(sum(top) / len(top)) + + def _vector_store_role_score(self, role: str, candidate: Dict[str, Any], reference: Dict[str, Any]) -> float: + entry = self._vector_store_entry(candidate) + if not entry: + return 0.5 + + entry_type = str(entry.get("type", "") or "").lower() + duration = float(entry.get("duration_estimate", candidate.get("duration", 0.0)) or 0.0) + tags = [str(tag).lower() for tag in entry.get("tags", []) if tag] + file_name = str(candidate.get("file_name", entry.get("filename", "")) or "").lower() + + type_score = 0.6 if not entry_type else (1.0 if entry_type in ROLE_VECTOR_TYPES.get(role, set()) else 0.35) + duration_score = self._duration_score(role, duration, file_name) + tag_score = self._naming_score(role, " ".join(tags + [file_name])) + tempo_score = self._tempo_score(float(entry.get("bpm", candidate.get("tempo", 0.0)) or 0.0), float(reference.get("tempo", 0.0) or 0.0)) + score = type_score * 0.34 + duration_score * 0.28 + tag_score * 0.26 + tempo_score * 0.12 + if role == 'crash_fx' and any(marker in file_name for marker in ['top loop', 'top loops', 'hat', 'snare']): + score *= 0.25 + return max(0.0, min(1.0, score)) + + def _role_score( + self, + role: str, + reference: Dict[str, Any], + candidate: Dict[str, Any], + cosine_score: float, + segment_score: float = 0.0, + catalog_score: float = 0.5, + ) -> float: + if segment_score > 0: + if role in {'kick', 'snare', 'hat', 'crash_fx', 'fill_fx', 'snare_roll', 'vocal_shot'}: + cosine_score = (float(cosine_score) * 0.28) + (float(segment_score) * 0.72) + elif role in {'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx'}: + cosine_score = (float(cosine_score) * 0.42) + (float(segment_score) * 0.58) + else: + cosine_score = (float(cosine_score) * 0.5) + (float(segment_score) * 0.5) + tempo_score = self._tempo_score(float(candidate.get("tempo", 0.0)), float(reference.get("tempo", 0.0))) + key_distance = _key_distance(reference.get("key"), candidate.get("key")) + key_score = max(0.0, 1.0 - (key_distance / 6.0)) + duration = float(candidate.get("duration", 0.0)) + onset = float(candidate.get("onset_mean", 0.0)) + rms = float(candidate.get("rms_mean", 0.0)) + file_name = str(candidate.get("file_name", "") or "").lower() + duration_score = self._duration_score(role, duration, file_name) + naming_score = self._naming_score(role, file_name) + spectral_score = self._spectral_role_score(role, candidate) + + if role in ['kick', 'snare', 'hat']: + base_score = ( + cosine_score * 0.18 + + tempo_score * 0.10 + + min(1.0, onset / 4.0) * 0.20 + + duration_score * 0.22 + + naming_score * 0.18 + + spectral_score * 0.12 + ) + elif role == 'bass_loop': + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.20 + + key_score * 0.20 + + duration_score * 0.16 + + min(1.0, rms / 0.5) * 0.08 + + spectral_score * 0.12 + ) + elif role in ['perc_loop', 'top_loop']: + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.26 + + key_score * 0.06 + + duration_score * 0.16 + + min(1.0, onset / 3.5) * 0.16 + + spectral_score * 0.12 + ) + elif role == 'synth_loop': + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.16 + + key_score * 0.22 + + duration_score * 0.16 + + naming_score * 0.10 + + spectral_score * 0.12 + ) + elif role == 'vocal_loop': + base_score = ( + cosine_score * 0.26 + + tempo_score * 0.20 + + key_score * 0.06 + + duration_score * 0.18 + + naming_score * 0.18 + + spectral_score * 0.12 + ) + elif role == 'crash_fx': + base_score = ( + cosine_score * 0.14 + + tempo_score * 0.06 + + duration_score * 0.28 + + naming_score * 0.32 + + min(1.0, onset / 3.0) * 0.08 + + spectral_score * 0.12 + ) + elif role == 'fill_fx': + base_score = ( + cosine_score * 0.16 + + tempo_score * 0.16 + + duration_score * 0.22 + + naming_score * 0.22 + + min(1.0, onset / 3.0) * 0.12 + + spectral_score * 0.12 + ) + elif role == 'snare_roll': + base_score = ( + cosine_score * 0.14 + + tempo_score * 0.12 + + duration_score * 0.20 + + naming_score * 0.28 + + min(1.0, onset / 2.5) * 0.14 + + spectral_score * 0.12 + ) + elif role == 'atmos_fx': + base_score = ( + cosine_score * 0.28 + + tempo_score * 0.06 + + key_score * 0.16 + + duration_score * 0.22 + + naming_score * 0.16 + + spectral_score * 0.12 + ) + elif role == 'vocal_shot': + base_score = ( + cosine_score * 0.20 + + tempo_score * 0.10 + + key_score * 0.12 + + duration_score * 0.20 + + naming_score * 0.26 + + spectral_score * 0.12 + ) + else: + base_score = cosine_score * 0.5 + tempo_score * 0.3 + key_score * 0.2 + + return float(base_score) * (0.82 + (0.24 * float(catalog_score))) + + def _spectral_role_score(self, role: str, candidate: Dict[str, Any]) -> float: + """Score candidate based on spectral characteristics for the role.""" + centroid = float(candidate.get("spectral_centroid", 0.0)) + rolloff = float(candidate.get("spectral_rolloff", 0.0)) + rms_std = float(candidate.get("rms_std", 0.0)) + onset_mean = float(candidate.get("onset_mean", 0.0)) + rms_mean = float(candidate.get("rms_mean", 0.0)) + + # Compute spectral spread indicator + rms_spread = min(1.0, rms_std / max(0.01, rms_mean)) if rms_mean > 0 else 0.5 + + # Transient score based on onset + transient_score = min(1.0, onset_mean / 3.0) + + # Get expected signature for role + sig = SPECTRAL_ROLE_SIGNATURES.get(role) + if not sig: + return 0.5 + + score = 0.0 + + # Centroid match + centroid_min, centroid_max = sig.get('centroid_range', (0, 20000)) + if centroid_min <= centroid <= centroid_max: + score += 0.25 + else: + # Partial score for being close + dist = min(abs(centroid - centroid_min), abs(centroid - centroid_max)) + score += 0.25 * math.exp(-dist / 2000) + + # Rolloff match + rolloff_min, rolloff_max = sig.get('rolloff_range', (0, 20000)) + if rolloff_min <= rolloff <= rolloff_max: + score += 0.25 + else: + dist = min(abs(rolloff - rolloff_min), abs(rolloff - rolloff_max)) + score += 0.25 * math.exp(-dist / 3000) + + # RMS spread match (for one-shots vs loops) + spread_min, spread_max = sig.get('rms_spread', (0.0, 1.0)) + if spread_min <= rms_spread <= spread_max: + score += 0.25 + else: + dist = min(abs(rms_spread - spread_min), abs(rms_spread - spread_max)) + score += 0.25 * math.exp(-dist / 0.3) + + # Transient score match + trans_min, trans_max = sig.get('transient_score', (0.0, 1.0)) + if trans_min <= transient_score <= trans_max: + score += 0.25 + else: + dist = min(abs(transient_score - trans_min), abs(transient_score - trans_max)) + score += 0.25 * math.exp(-dist / 0.3) + + return min(1.0, max(0.0, score)) + + def _duration_score(self, role: str, duration: float, file_name: str) -> float: + """Improved duration scoring with better one-shot vs loop detection.""" + file_lower = file_name.lower() + + # One-shot roles: kick, snare/clap, hat + if role in ['kick', 'snare', 'hat']: + # Ideal one-shot duration: 0.1 - 1.5 seconds + is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower + is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower + + if is_explicit_shot and duration < 3.0: + return 1.0 + if is_explicit_loop: + return 0.35 + + # Duration-based scoring for one-shots + if duration < 0.1: + return 0.4 # Too short, probably artifact + if duration < 2.0: + # Sweet spot for one-shots + peak = 0.5 if role == 'kick' else (0.8 if role == 'hat' else 0.6) + score = math.exp(-abs(duration - peak) / 1.0) + return max(0.0, min(1.0, score)) + if duration < 4.0: + # Could be a roll or extended hit + return 0.5 if 'roll' in file_lower else 0.3 + return 0.2 # Too long for one-shot + + # Loop roles: bass, perc, top, synth, vocal + if role in ['bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop']: + is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower + is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower + + if is_explicit_shot: + return 0.25 # One-shot marked as loop role + + # Ideal loop duration: 2 - 16 seconds (typically 4 or 8 bars) + if duration < 0.5: + return 0.2 # Too short for a proper loop + if duration < 2.0: + # Short loop, acceptable but not ideal + base_score = duration / 2.0 + if is_explicit_loop: + base_score += 0.2 + return min(1.0, base_score) + if duration < 12.0: + # Sweet spot for loops (2-8 bars typically) + score = min(1.0, duration / 6.0) + if is_explicit_loop: + score = min(1.0, score + 0.15) + return score + if duration < 20.0: + # Longer loop, still acceptable + return 0.75 if is_explicit_loop else 0.6 + return 0.5 # Very long loop + + # FX roles + if role == 'crash_fx': + # Crashes: 0.5 - 4 seconds + if any(marker in file_lower for marker in ['loop', 'top', 'hat', 'snare']): + return 0.15 + if duration < 0.3: + return 0.3 + if duration < 5.0: + return math.exp(-abs(duration - 2.0) / 2.5) + return 0.4 + + if role in ['fill_fx', 'snare_roll']: + # Fills/rolls: 1 - 8 seconds + if duration < 0.5: + return 0.3 + if duration < 8.0: + return math.exp(-abs(duration - 4.0) / 3.0) + return 0.5 + + if role == 'atmos_fx': + # Atmos: longer, sustained sounds + if duration < 2.0: + return 0.4 + if duration < 30.0: + return min(1.0, duration / 12.0) + return 0.8 + + if role == 'vocal_shot': + # Vocal shots: short one-shots + if duration < 0.2: + return 0.5 + if duration < 2.0: + return math.exp(-abs(duration - 0.8) / 1.2) + if duration < 4.0: + return 0.4 + return 0.25 + + return 0.5 + + def _naming_score(self, role: str, file_name: str) -> float: + if role == 'kick': + if 'loop' in file_name: + return 0.45 + return 1.0 if 'kick' in file_name else 0.7 + if role == 'snare': + if 'roll' in file_name: + return 0.4 + if 'clap' in file_name or 'snare' in file_name: + return 1.0 + return 0.7 + if role == 'hat': + if 'loop' in file_name: + return 0.7 + if 'closed' in file_name or 'hat' in file_name: + return 1.0 + return 0.75 + if role == 'vocal_loop': + if 'vocal' in file_name or 'vox' in file_name: + return 1.0 + return 0.7 + if role == 'top_loop': + if 'top' in file_name or 'full drum' in file_name: + return 1.0 + if 'perc' in file_name: + return 0.58 + return 0.85 if 'loop' in file_name else 0.65 + if role in ['bass_loop', 'perc_loop', 'synth_loop']: + return 1.0 if 'loop' in file_name else 0.72 + if role == 'crash_fx': + if 'crash' in file_name: + return 1.0 + if 'impact' in file_name: + return 0.9 + if any(marker in file_name for marker in ['top loop', 'top loops', 'closed hat', 'open hat', 'snare', 'roll']): + return 0.2 + return 0.65 + if role == 'fill_fx': + if 'fill' in file_name: + return 1.0 + if 'tom' in file_name or 'roll' in file_name: + return 0.84 + return 0.62 + if role == 'snare_roll': + if 'roll' in file_name: + return 1.0 + if 'snare' in file_name or 'fill' in file_name: + return 0.82 + return 0.55 + if role == 'atmos_fx': + if 'atmos' in file_name: + return 1.0 + if 'drone' in file_name or 'noise' in file_name: + return 0.82 + return 0.64 + if role == 'vocal_shot': + if 'vocal' in file_name or 'importante' in file_name: + return 1.0 + if 'shot' in file_name: + return 0.88 + return 0.64 + return 0.8 + + def _candidate_path(self, item: Optional[Dict[str, Any]]) -> str: + if not isinstance(item, dict): + return "" + return str(item.get("path", "") or "").strip().lower() + + def _candidate_family(self, item: Optional[Dict[str, Any]]) -> str: + if not isinstance(item, dict): + return "" + + file_name = str(item.get("file_name", "") or Path(str(item.get("path", "") or "")).name).strip().lower() + stem = Path(file_name).stem.lower() + if not stem: + return "" + + markers = [ + " - kick", " - snare", " - clap", " - closed hat", " - open hat", " - hat", + " - bass loop", " - percussion loop", " - percussion", " - perc loop", + " - top loop", " - synth loop", " - vocal loop", " - vocal one shot", + " - fill", " - snareroll", " - snare roll", " - crash", " - atmos", + ] + for marker in markers: + if marker in stem: + return stem.split(marker, 1)[0].strip() + + if " - " in stem: + return " - ".join(part.strip() for part in stem.split(" - ")[:2] if part.strip()) + if "_" in stem: + return "_".join(stem.split("_")[:2]).strip("_") + + words = stem.split() + return " ".join(words[:2]) if words else stem + + def _remember_candidate(self, item: Optional[Dict[str, Any]]) -> None: + path_key = self._candidate_path(item) + family_key = self._candidate_family(item) + if path_key: + self._recent_paths.append(path_key) + if hasattr(self, '_generation_path_usage'): + self._generation_path_usage[path_key] += 1 + if family_key: + self._recent_families.append(family_key) + # Track usage count for progressive penalty + self._family_usage_count[family_key] = self._family_usage_count.get(family_key, 0) + 1 + if hasattr(self, '_generation_family_usage'): + self._generation_family_usage[family_key] += 1 + + def _get_family_penalty(self, family_key: str) -> float: + """Calculate progressive penalty for repeated families.""" + if not family_key: + return 1.0 + + if family_key in self._recent_families: + return 0.08 + + usage_count = self._family_usage_count.get(family_key, 0) + if usage_count == 0: + return 1.0 + if usage_count == 1: + return 0.45 + if usage_count == 2: + return 0.22 + if usage_count >= 3: + return 0.08 + + return 1.0 + + def _get_cross_generation_family_penalty(self, family_key: str) -> float: + """Penaliza familias usadas en generaciones previas de referencia.""" + if not family_key: + return 1.0 + usage_count = int(_cross_generation_reference_family_memory.get(family_key, 0) or 0) + if usage_count <= 0: + return 1.0 + if usage_count == 1: + return 0.55 + if usage_count == 2: + return 0.30 + if usage_count >= 3: + return 0.08 + return max(0.08, 1.0 - (usage_count * 0.18)) + + def _get_cross_generation_path_penalty(self, path_key: str) -> float: + """Penaliza paths usados en generaciones previas de referencia.""" + if not path_key: + return 1.0 + usage_count = int(_cross_generation_reference_path_memory.get(path_key, 0) or 0) + if usage_count <= 0: + return 1.0 + if usage_count == 1: + return 0.40 + if usage_count >= 2: + return 0.15 + return max(0.25, 1.0 - (usage_count * 0.20)) + + def _select_candidate(self, role: str, items: List[Dict[str, Any]], rng: random.Random, + section_kind: str = "", section_energy: float = 0.5) -> Optional[Dict[str, Any]]: + if not items: + return None + + pool_sizes = { + "kick": 16, + "snare": 16, + "hat": 18, + "bass_loop": 14, + "perc_loop": 16, + "top_loop": 14, + "synth_loop": 14, + "vocal_loop": 12, + "crash_fx": 10, + "fill_fx": 12, + "snare_roll": 10, + "atmos_fx": 10, + "vocal_shot": 12, + } + pool_size = min(pool_sizes.get(role, 10), len(items)) + candidates = list(items[:pool_size]) + + section_bonus = { + 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': -0.06, 'outro': 0.02}, + 'snare': {'intro': -0.08, 'verse': 0.06, 'build': 0.10, 'drop': 0.12, 'break': 0.04, 'outro': -0.06}, + 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.12, 'drop': 0.10, 'break': -0.04, 'outro': 0.02}, + 'bass_loop': {'intro': -0.10, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': -0.04}, + 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.04, 'outro': 0.00}, + 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.02, 'outro': 0.00}, + 'synth_loop': {'intro': 0.04, 'verse': 0.06, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.02}, + 'vocal_loop': {'intro': -0.04, 'verse': 0.12, 'build': 0.08, 'drop': 0.14, 'break': 0.08, 'outro': -0.02}, + 'crash_fx': {'intro': 0.08, 'verse': 0.02, 'build': 0.14, 'drop': 0.08, 'break': -0.04, 'outro': 0.08}, + 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.16, 'drop': 0.10, 'break': 0.08, 'outro': 0.02}, + 'snare_roll': {'intro': -0.06, 'verse': 0.02, 'build': 0.22, 'drop': 0.12, 'break': 0.04, 'outro': -0.08}, + 'atmos_fx': {'intro': 0.20, 'verse': 0.04, 'build': 0.02, 'drop': -0.04, 'break': 0.20, 'outro': 0.16}, + 'vocal_shot': {'intro': -0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': 0.06, 'outro': -0.02}, + } + + weighted: List[Tuple[float, Dict[str, Any]]] = [] + + for index, item in enumerate(candidates): + score = max(0.001, float(item.get("score", 0.001))) + rank_penalty = max(0.30, 1.0 - (index * 0.055)) + + passes_validation, validation_mod, validation_reason = self._validate_role_requirement(role, item) + if not passes_validation: + continue + + score *= validation_mod + + path_key = self._candidate_path(item) + path_penalty = 0.12 if path_key in self._recent_paths else 1.0 + + family_key = self._candidate_family(item) + family_penalty = self._get_family_penalty(family_key) + cross_family_penalty = self._get_cross_generation_family_penalty(family_key) + cross_path_penalty = self._get_cross_generation_path_penalty(path_key) + + section_bonus_val = section_bonus.get(role.lower(), {}).get(section_kind.lower(), 0.0) + if section_kind.lower() in {'drop', 'build'} and section_energy > 0.7: + section_bonus_val *= 1.2 + elif section_kind.lower() in {'break', 'intro'} and section_energy < 0.4: + section_bonus_val *= 1.2 + + energy_mod = 1.0 + rms = float(item.get("rms_mean", 0.0) or 0.0) + if role.lower() in {"kick", "snare", "bass_loop"}: + if rms > 0.08: + energy_mod = min(1.15, 1.0 + (rms - 0.08) * 2.0) + elif rms < 0.03 and section_kind.lower() not in {"intro", "break"}: + energy_mod = 0.85 + + role_randomness = 0.88 + (rng.random() * 0.24) + + weight = ( + (score ** 1.7) + * rank_penalty + * path_penalty + * family_penalty + * cross_family_penalty + * cross_path_penalty + * role_randomness + * energy_mod + ) + + if section_bonus_val > 0: + weight *= (1.0 + section_bonus_val) + elif section_bonus_val < 0: + weight *= (1.0 + section_bonus_val * 0.5) + + weighted.append((max(0.001, weight), item)) + + if not weighted: + weighted = [(max(0.001, float(item.get("score", 0.001))), item) for item in candidates] + + total = sum(weight for weight, _ in weighted) + if total <= 0: + return candidates[0] if candidates else None + + pivot = rng.random() * total + running = 0.0 + for weight, item in weighted: + running += weight + if pivot <= running: + return item + + return weighted[0][1] + + def _select_distinct_candidate( + self, + role: str, + items: List[Dict[str, Any]], + rng: random.Random, + used_paths: set, + used_families: set, + section_kind: str = "", + section_energy: float = 0.5, + ) -> Optional[Dict[str, Any]]: + if not items: + return None + + filtered = [ + item for item in items + if self._candidate_path(item) not in used_paths + ] + + family_filtered = [ + item for item in filtered + if self._candidate_family(item) not in used_families + ] + + pool = family_filtered if family_filtered else filtered if filtered else items + + selected = self._select_candidate(role, pool, rng, section_kind, section_energy) + selected_path = self._candidate_path(selected) + selected_family = self._candidate_family(selected) + + if selected_path: + used_paths.add(selected_path) + if selected_family: + used_families.add(selected_family) + + self._remember_candidate(selected) + return selected + + def reset_family_tracking(self) -> None: + """Reset family usage tracking for a new generation.""" + self._family_usage_count.clear() + self._recent_families.clear() + self._recent_paths.clear() + + def start_generation_tracking(self) -> None: + """Inicia tracking de paths/familias para una generación nueva.""" + self._generation_family_usage = defaultdict(int) + self._generation_path_usage = defaultdict(int) + + def end_generation_tracking(self) -> None: + """Actualiza memoria cross-generation de la ruta de referencia.""" + for key in list(_cross_generation_reference_family_memory.keys()): + _cross_generation_reference_family_memory[key] = max(0, _cross_generation_reference_family_memory[key] - 1) + for key in list(_cross_generation_reference_path_memory.keys()): + _cross_generation_reference_path_memory[key] = max(0, _cross_generation_reference_path_memory[key] - 1) + + for family, count in dict(getattr(self, '_generation_family_usage', {})).items(): + if family: + _cross_generation_reference_family_memory[family] += int(count) + for path_key, count in dict(getattr(self, '_generation_path_usage', {})).items(): + if path_key: + _cross_generation_reference_path_memory[path_key] += int(count) + + for key in list(_cross_generation_reference_family_memory.keys()): + if _cross_generation_reference_family_memory[key] <= 0: + del _cross_generation_reference_family_memory[key] + for key in list(_cross_generation_reference_path_memory.keys()): + if _cross_generation_reference_path_memory[key] <= 0: + del _cross_generation_reference_path_memory[key] + + if hasattr(self, '_generation_family_usage'): + delattr(self, '_generation_family_usage') + if hasattr(self, '_generation_path_usage'): + delattr(self, '_generation_path_usage') + + def reset_cross_generation_tracking(self) -> None: + """Resetea la memoria de diversidad entre generaciones para referencia.""" + _cross_generation_reference_family_memory.clear() + _cross_generation_reference_path_memory.clear() + + def reset_recent_sample_diversity_memory(self) -> None: + """Resetea la memoria de diversidad de samples recientes por rol.""" + global _recent_sample_diversity_memory + _recent_sample_diversity_memory.clear() + + def sync_recent_memory_from_selector(self) -> None: + """Sync recent sample diversity memory from sample_selector module.""" + global _recent_sample_diversity_memory + try: + from .sample_selector import _recent_sample_diversity_memory as selector_memory + for role, paths in selector_memory.items(): + if role not in _recent_sample_diversity_memory: + _recent_sample_diversity_memory[role] = [] + for path in paths: + if path not in _recent_sample_diversity_memory[role]: + _recent_sample_diversity_memory[role].append(path) + except ImportError: + pass + + def get_recent_sample_diversity_state(self) -> Dict[str, List[str]]: + """Get copy of recent sample diversity memory.""" + return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()} + + def match_assets(self, reference_path: str) -> Dict[str, Any]: + reference = self.analyze_reference(reference_path) + reference_sections = self.detect_reference_sections(reference_path) + segment_bank = self._build_reference_segment_bank(reference_path, reference, reference_sections) + assets = self._list_assets() + matches: Dict[str, List[Dict[str, Any]]] = {} + role_segments = { + role: self._select_role_reference_segments(role, reference, segment_bank) + for role in assets.keys() + } + rerank_limits = { + "kick": 14, + "snare": 14, + "hat": 16, + "bass_loop": 12, + "perc_loop": 14, + "top_loop": 12, + "synth_loop": 12, + "vocal_loop": 12, + "crash_fx": 10, + "fill_fx": 10, + "snare_roll": 10, + "atmos_fx": 8, + "vocal_shot": 10, + } + + for role, files in assets.items(): + analyses: List[Dict[str, Any]] = [] + vectors: List[List[float]] = [] + for file_path in files: + try: + analysis = self.analyze_file(str(file_path), duration_limit=64.0) + except Exception: + continue + analyses.append(analysis) + vectors.append(list(analysis.get("vector", []))) + + scores = self._cosine_scores(reference.get("vector", []), vectors) + role_matches: List[Dict[str, Any]] = [] + for analysis, cosine_score in zip(analyses, scores): + catalog_score = self._vector_store_role_score(role, analysis, reference) + preliminary_score = self._role_score( + role, + reference, + analysis, + float(cosine_score), + segment_score=0.0, + catalog_score=catalog_score, + ) + role_matches.append({ + "_analysis": analysis, + "_cosine": float(cosine_score), + "_catalog": float(catalog_score), + "_preliminary": float(preliminary_score), + }) + + role_matches.sort(key=lambda item: item["_preliminary"], reverse=True) + rerank_limit = min(int(rerank_limits.get(role, 10) or 10), len(role_matches)) + + role_section_features = self._section_detector._get_role_section_features(role, reference_sections, role_segments.get(role, [])) + + finalized_matches: List[Dict[str, Any]] = [] + for index, item in enumerate(role_matches): + analysis = item["_analysis"] + cosine_score = float(item["_cosine"]) + catalog_score = float(item["_catalog"]) + segment_score = 0.0 + character_bonus = 1.0 + final_score = float(item["_preliminary"]) + + if index < rerank_limit: + segment_score = self._role_segment_similarity(role, analysis, role_segments.get(role, [])) + final_score = self._role_score( + role, + reference, + analysis, + cosine_score, + segment_score=segment_score, + catalog_score=catalog_score, + ) + + if role_section_features: + character_bonus = self._section_detector._section_character_bonus( + role, role_section_features, analysis + ) + final_score = final_score * character_bonus + + finalized_matches.append({ + "path": analysis["path"], + "file_name": analysis["file_name"], + "tempo": analysis["tempo"], + "key": analysis["key"], + "duration": analysis["duration"], + "cosine": round(float(cosine_score), 6), + "segment_score": round(float(segment_score), 6), + "catalog_score": round(float(catalog_score), 6), + "character_bonus": round(float(character_bonus), 3), + "score": round(float(final_score), 6), + }) + + finalized_matches.sort(key=lambda item: item["score"], reverse=True) + matches[role] = finalized_matches + + # Build section energy profile for generator + section_energy_profile = [] + for section in reference_sections: + features = section.get('features', {}) + section_energy_profile.append({ + 'kind': section.get('kind', 'drop'), + 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), + 'energy_peak': features.get('energy_peak', 0.5), + 'energy_slope': features.get('energy_slope', 0.0), + 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), + 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), + 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), + 'low_energy_ratio': features.get('low_energy_ratio', 0.0), + 'high_energy_ratio': features.get('high_energy_ratio', 0.0), + 'kind_confidence': section.get('kind_confidence', 0.5), + }) + + return { + "reference": reference, + "reference_sections": reference_sections, + "segment_bank_size": len(segment_bank), + "role_segments": { + role: [ + { + "start": segment.get("start"), + "end": segment.get("end"), + "kind": segment.get("kind"), + "window_seconds": segment.get("window_seconds"), + } + for segment in items + ] + for role, items in role_segments.items() + }, + "matches": matches, + "section_energy_profile": section_energy_profile, + "device": self.device_name, + } + + def _section_offsets(self, sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: + offsets: List[Tuple[Dict[str, Any], float, float]] = [] + position = 0.0 + for section in sections: + beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) + start = position + end = position + beats + offsets.append((section, start, end)) + position = end + return offsets + + def _section_energy(self, reference: Dict[str, Any], progress: float) -> float: + blocks = reference.get("blocks", []) + if not blocks: + return 0.5 + index = min(len(blocks) - 1, max(0, int(round(progress * (len(blocks) - 1))))) + return float(blocks[index].get("energy_norm", 0.5)) + + def _loop_step_beats(self, item: Optional[Dict[str, Any]], project_bpm: float, default_beats: float = 16.0) -> float: + if not item: + return default_beats + duration = float(item.get("duration", 0.0)) + source_tempo = float(item.get("tempo", 0.0)) + if duration <= 0: + return default_beats + if source_tempo > 0: + source_beats = duration * source_tempo / 60.0 + rounded = max(4.0, round(source_beats / 4.0) * 4.0) + return float(rounded) + estimated = duration * project_bpm / 60.0 + rounded = max(4.0, round(estimated / 4.0) * 4.0) + return float(rounded) + + def _detect_roles_for_segment(self, features: Dict[str, float], section_kind: str) -> List[str]: + """Detect appropriate roles for a segment based on its features and section type.""" + roles = [] + energy = features.get('energy', 0.5) + onset = features.get('onset_density', 0.5) + brightness = features.get('brightness', 0.5) + + # Drums are always present in non-intro/outro sections + if section_kind in ['drop', 'build', 'verse']: + roles.extend(['kick', 'snare', 'hat']) + + # Bass is present in high-energy sections + if section_kind in ['drop', 'build'] or energy > 0.5: + roles.append('bass_loop') + + # Percussion and top loops based on onset density + if onset > 0.4: + roles.extend(['perc_loop', 'top_loop']) + + # Synths in drops and high-brightness sections + if section_kind == 'drop' or (brightness > 0.5 and energy > 0.6): + roles.append('synth_loop') + + # Vocals in drops and verse sections + if section_kind in ['drop', 'verse']: + roles.extend(['vocal_loop', 'vocal_shot']) + + # FX based on section type + if section_kind == 'build': + roles.extend(['snare_roll', 'fill_fx', 'crash_fx']) + elif section_kind == 'break': + roles.extend(['atmos_fx', 'fill_fx']) + elif section_kind == 'intro': + roles.extend(['atmos_fx', 'crash_fx']) + elif section_kind == 'outro': + roles.extend(['atmos_fx', 'crash_fx']) + + return list(set(roles)) + + def _analyze_segment_roles(self, reference: Dict[str, Any], sections: List[Dict[str, Any]]) -> Dict[str, List[str]]: + """Analyze and return recommended roles for each section.""" + segment_roles: Dict[str, List[str]] = {} + + for i, section in enumerate(sections): + kind = str(section.get("kind", "drop")).lower() + + # Use features if available from automatic detection + features = section.get("features", { + 'energy': 0.5, + 'onset_density': 0.5, + 'brightness': 0.5, + }) + + # Estimate features from position if not available + if 'energy' not in features: + blocks = reference.get("blocks", []) + if blocks: + progress = i / max(1, len(sections) - 1) + idx = min(len(blocks) - 1, max(0, int(progress * (len(blocks) - 1)))) + features['energy'] = float(blocks[idx].get("energy_norm", 0.5)) + + roles = self._detect_roles_for_segment(features, kind) + segment_roles[f"section_{i}_{kind}"] = roles + + return segment_roles + + def detect_reference_sections(self, reference_path: str, min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: + """Automatically detect sections from a reference track with richer feature extraction.""" + if librosa is None: + raise RuntimeError("librosa no está disponible") + + path = Path(reference_path) + y, sr = librosa.load(str(path), sr=22050, mono=True) + hop_length = 512 + n_fft = _adaptive_n_fft(len(y)) + + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] + + duration = float(librosa.get_duration(y=y, sr=sr)) + + sections = self._section_detector.detect_sections( + rms, onset_env, centroid, duration, min_section_seconds + ) + + tempo = float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median) or 128) + + if len(sections) < 2 and duration > min_section_seconds * 1.5: + mid = duration / 2 + energy_first_half = float(np.mean(rms[:int(len(rms)/2)])) if len(rms) > 0 else 0.5 + energy_second_half = float(np.mean(rms[int(len(rms)/2):])) if len(rms) > 1 else 0.5 + + if energy_first_half < energy_second_half * 0.8: + sections = [ + {'kind': 'intro', 'start': 0.0, 'end': mid * 0.4, 'duration': mid * 0.4, + 'bars': max(4, int(mid * 0.4 * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, + {'kind': 'build', 'start': mid * 0.4, 'end': mid, 'duration': mid * 0.6, + 'bars': max(4, int(mid * 0.6 * tempo / 60 / 4)), 'features': {'energy': (energy_first_half + energy_second_half) / 2}}, + {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, + ] + else: + sections = [ + {'kind': 'verse', 'start': 0.0, 'end': mid, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, + {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, + ] + + prev_features = None + total_sections = len(sections) + for i, section in enumerate(sections): + sec_duration = section.get('duration', 8.0) + beats_per_second = tempo / 60.0 + beats = sec_duration * beats_per_second + bars = max(4, int(round(beats / 4.0))) + section['bars'] = bars + section['beats'] = bars * 4 + section['tempo'] = round(tempo, 1) + section['section_index'] = i + section['total_sections'] = total_sections + + start_time = float(section.get('start', 0.0)) + end_time = float(section.get('end', sec_duration)) + + # Compute richer section features inline (method was in wrong class) + duration_sec = end_time - start_time + frames_per_second = sr / hop_length + start_frame = int(start_time * frames_per_second) + end_frame = int(end_time * frames_per_second) + start_frame = max(0, min(start_frame, len(rms) - 1)) + end_frame = max(start_frame + 1, min(end_frame, len(rms))) + + section_rms = rms[start_frame:end_frame] if end_frame > start_frame else np.array([0.0]) + rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 + energy_mean = float(np.mean(section_rms)) if len(section_rms) > 0 else 0.0 + energy_peak = float(np.max(section_rms)) if len(section_rms) > 0 else 0.0 + energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) + energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) + + richer_features = { + 'energy_mean': round(energy_mean_norm, 3), + 'energy_peak': round(energy_peak_norm, 3), + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.5, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.5, + 'low_energy_ratio': 0.3, + 'high_energy_ratio': 0.3, + } + + if 'features' not in section: + section['features'] = {} + section['features'].update(richer_features) + + kind = str(section.get('kind', 'drop')).lower() + position_ratio = start_time / max(duration, 0.001) + section['features']['total_sections'] = total_sections + + # Simple confidence calculation inline + energy = section['features'].get('energy', 0.5) + onset_density = section['features'].get('onset_density', 0.5) + + # Basic confidence based on energy and position + if kind == 'intro' and position_ratio < 0.2: + confidence = 0.7 + elif kind == 'outro' and position_ratio > 0.8: + confidence = 0.7 + elif kind == 'drop' and energy > 0.6: + confidence = 0.75 + elif kind == 'build' and 0.3 < position_ratio < 0.7: + confidence = 0.65 + elif kind == 'break' and 0.4 < position_ratio < 0.8: + confidence = 0.6 + else: + confidence = 0.5 + + section['kind_confidence'] = confidence + alternatives = [] + if confidence < 0.55: + alternatives = ['drop', 'build', 'break'] + section['kind_alternatives'] = alternatives + + prev_features = section['features'] + + sections = self._validate_section_sequence(sections, duration, tempo) + + return sections + + def _validate_section_sequence(self, sections: List[Dict[str, Any]], + duration: float, tempo: float) -> List[Dict[str, Any]]: + """Validate and potentially correct section sequence for musical coherence.""" + if len(sections) < 2: + return sections + + result = [] + sequence_issues = [] + + VALID_TRANSITIONS = { + 'intro': {'verse', 'build', 'break', 'drop'}, + 'verse': {'build', 'drop', 'break', 'verse', 'outro'}, + 'build': {'drop', 'break', 'verse'}, + 'drop': {'break', 'verse', 'build', 'outro', 'drop'}, + 'break': {'build', 'drop', 'verse', 'outro'}, + 'outro': set(), + } + + PREFERRED_FIRST = {'intro', 'verse', 'build', 'break'} + PREFERRED_LAST = {'outro', 'drop', 'break'} + + for i, section in enumerate(sections): + kind = section.get('kind', 'drop') + confidence = section.get('kind_confidence', 0.5) + alternatives = section.get('kind_alternatives', []) + + section_copy = dict(section) + + if i == 0: + if kind not in PREFERRED_FIRST: + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in PREFERRED_FIRST: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'first_section_adjusted' + section_copy['original_kind'] = kind + break + elif confidence < 0.45: + section_copy['sequence_warning'] = f'first_section_is_{kind}' + + if i == len(sections) - 1: + if kind not in PREFERRED_LAST: + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in PREFERRED_LAST: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'last_section_adjusted' + section_copy['original_kind'] = kind + break + elif confidence < 0.45: + section_copy['sequence_warning'] = f'last_section_is_{kind}' + + if 0 < i < len(sections) - 1: + prev_kind = sections[i - 1].get('kind', 'drop') + next_kind = sections[i + 1].get('kind', 'drop') if i + 1 < len(sections) else None + + valid_prev = kind in VALID_TRANSITIONS.get(prev_kind, set()) + + if not valid_prev and confidence < 0.60: + transition_key = f'{prev_kind}_to_{kind}' + sequence_issues.append(transition_key) + + if alternatives: + for alt in alternatives: + if alt in VALID_TRANSITIONS.get(prev_kind, set()): + if next_kind is None or next_kind in VALID_TRANSITIONS.get(alt, set()): + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'transition_fixed' + section_copy['original_kind'] = kind + section_copy['invalid_transition'] = transition_key + break + + if kind == 'build': + next_kind = sections[i + 1].get('kind', '') if i < len(sections) - 1 else None + if next_kind and next_kind not in ('drop', 'break', 'verse'): + next_confidence = sections[i + 1].get('kind_confidence', 0.5) + if next_confidence < 0.60: + section_copy['build_transition_warning'] = f'build_followed_by_{next_kind}' + + if kind == 'drop': + features = section.get('features', {}) + energy = features.get('energy', 0.5) + if energy < 0.50: + section_copy['drop_energy_warning'] = f'drop_has_low_energy_{energy:.2f}' + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in {'verse', 'build'}: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'low_energy_drop_reclassified' + section_copy['original_kind'] = 'drop' + break + + result.append(section_copy) + + if sequence_issues: + result[0]['sequence_issues'] = sequence_issues[:5] + + return result + + def _get_section_variant(self, section_kind: str, section_name: str = "") -> str: + """ + Determina la variante apropiada para una sección. + + Retorna un string como 'sparse', 'dense', 'full', etc. + """ + kind_lower = section_kind.lower() + name_lower = section_name.lower() + + # Detectar variantes especiales por nombre + if 'peak' in name_lower or 'main' in name_lower: + return 'peak' + if 'minimal' in name_lower: + return 'minimal' + if 'atmos' in name_lower: + return 'atmospheric' + + # Usar defaults por tipo + return SECTION_VARIANTS.get(kind_lower, ['standard'])[0] + + def _select_variant_samples(self, + base_samples: List[Any], + role: str, + section_variant: str, + target_key: str = None, + target_bpm: float = None) -> List[Any]: + """ + Selecciona samples apropiados para una variante de sección. + + Filtra y reordena base_samples según la variante: + - 'sparse': prefiere samples más ligeros/simples + - 'dense': prefiere samples más complejos + - 'full': usa samples principales + - 'minimal': usa samples más sutiles + """ + if not base_samples: + return base_samples + + # Por defecto, retornar sin cambios + if section_variant == 'standard': + return base_samples + + variant_samples = [] + + for sample in base_samples: + # Get sample name from the match dict + if isinstance(sample, dict): + sample_name = sample.get('file_name', '') + else: + sample_name = str(sample) + + name_lower = sample_name.lower() + + # Variant sparse/minimal: buscar keywords sutiles + if section_variant in ['sparse', 'minimal', 'atmospheric', 'fading']: + if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'simple', 'minimal', 'clean', 'thin']): + variant_samples.insert(0, sample) # Prioridad alta + elif any(kw in name_lower for kw in ['heavy', 'full', 'busy', 'complex', 'big', 'thick']): + continue # Skip para variantes sutiles + else: + variant_samples.append(sample) + + # Variant dense/full/peak: buscar keywords ricos + elif section_variant in ['dense', 'full', 'peak', 'building']: + if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main', 'thick']): + variant_samples.insert(0, sample) # Prioridad alta + elif any(kw in name_lower for kw in ['minimal', 'subtle', 'light', 'thin']): + continue # Skip para variantes ricas + else: + variant_samples.append(sample) + + else: + variant_samples.append(sample) + + # Si no quedan samples después del filtro, usar originals + return variant_samples if variant_samples else base_samples + + def _get_variant_samples_for_section(self, + base_samples: List[Any], + role: str, + section_kind: str, + section_name: str, + target_key: str = None, + target_bpm: float = None, + max_variants: int = 3) -> Dict[str, List[Any]]: + """ + Selecciona samples DIFERENTES para diferentes secciones de un mismo rol. + + Retorna un dict mapping section_key -> list of samples. + + Para roles variante (perc, top_loop, etc.), esto retorna samples distintos + para intro/verse/build/drop/break/outro cuando es posible. + """ + # Roles que pueden tener variación real + variant_roles = ['perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos'] + + if role not in variant_roles or not base_samples or len(base_samples) < 3: + # No hay suficiente pool para variación + return {'all': base_samples} + + section_map = {} + + # Variantes por tipo de sección + section_types = { + 'intro': ['minimal', 'sparse'], + 'verse': ['standard', 'light'], + 'build': ['building', 'adding'], + 'drop': ['full', 'peak', 'rich'], + 'break': ['sparse', 'atmospheric'], + 'outro': ['fading', 'minimal'] + } + + # Para cada sección, seleccionar samples con preferencias diferentes + section_key = f"{section_kind}_{section_name}" + + # Determinar preferencia para esta sección + variants = section_types.get(section_kind.lower(), ['standard']) + preference = variants[0] if variants else 'standard' + + # Filtrar samples según preferencia + variant_samples = [] + remaining_samples = list(base_samples) + + for sample in remaining_samples: + # Get sample name from the match dict + if isinstance(sample, dict): + sample_name = sample.get('file_name', '') + else: + sample_name = str(sample) + + name_lower = sample_name.lower() + + # Para sparse/minimal: buscar keywords ligeros + if preference in ['minimal', 'sparse', 'atmospheric']: + if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'minimal', 'clean', 'atmos']): + variant_samples.append(sample) + elif any(kw in name_lower for kw in ['heavy', 'hard', 'full', 'big']): + continue + + # Para full/peak: buscar keywords ricos + elif preference in ['full', 'peak', 'rich', 'building']: + if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main']): + variant_samples.append(sample) + elif any(kw in name_lower for kw in ['minimal', 'subtle']): + continue + + else: + variant_samples.append(sample) + + # Si no encontramos suficientes, usar del pool original + if len(variant_samples) < 2: + variant_samples = base_samples[:max_variants] + + section_map[section_key] = variant_samples[:max_variants] + + return section_map + + def build_arrangement_plan(self, reference_path: str, sections: List[Dict[str, Any]], + project_bpm: float, project_key: str, + variant_seed: Optional[int] = None) -> Dict[str, Any]: + # Reset family tracking for new generation + self.reset_family_tracking() + + result = self.match_assets(reference_path) + reference = result["reference"] + matches = result["matches"] + + # Auto-detect sections if not provided or enhance existing ones + if not sections: + sections = self.detect_reference_sections(reference_path) + + offsets = self._section_offsets(sections) + rng = random.Random(variant_seed if variant_seed is not None else random.SystemRandom().randint(1, 10**9)) + + # Analyze roles per segment + segment_roles = self._analyze_segment_roles(reference, sections) + + used_paths: set = set() + used_families: set = set() + selection_order = [ + "kick", + "snare", + "hat", + "bass_loop", + "perc_loop", + "top_loop", + "synth_loop", + "vocal_loop", + "crash_fx", + "fill_fx", + "snare_roll", + "atmos_fx", + "vocal_shot", + ] + selected: Dict[str, Optional[Dict[str, Any]]] = {} + for role in selection_order: + selected[role] = self._select_distinct_candidate(role, matches.get(role, []), rng, used_paths, used_families) + + perc_candidates = [ + item for item in matches.get("perc_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("perc_loop")) + ] + perc_alt = self._select_distinct_candidate("perc_loop", perc_candidates, rng, used_paths, used_families) if perc_candidates else None + synth_candidates = [ + item for item in matches.get("synth_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("synth_loop")) + ] + synth_alt = self._select_distinct_candidate("synth_loop", synth_candidates, rng, used_paths, used_families) if synth_candidates else None + vocal_candidates = [ + item for item in matches.get("vocal_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("vocal_loop")) + ] + vocal_alt = self._select_distinct_candidate("vocal_loop", vocal_candidates, rng, used_paths, used_families) if vocal_candidates else None + + def add_range(target: List[Tuple[float, Dict]], start: float, end: float, step: float, offset: float = 0.0, sample: Dict = None): + if sample is None: + return + cursor = start + offset + while cursor < end - 0.01: + target.append((round(float(cursor), 3), sample)) + cursor += step + + def add_hit(target: List[Tuple[float, Dict]], position: float, sample: Dict = None): + if position >= 0.0 and sample is not None: + target.append((round(float(position), 3), sample)) + + kick_positions: List[Tuple[float, Dict]] = [] + snare_positions: List[Tuple[float, Dict]] = [] + hat_positions: List[Tuple[float, Dict]] = [] + bass_positions: List[Tuple[float, Dict]] = [] + perc_positions: List[Tuple[float, Dict]] = [] + perc_alt_positions: List[Tuple[float, Dict]] = [] + top_loop_positions: List[Tuple[float, Dict]] = [] + synth_positions: List[Tuple[float, Dict]] = [] + synth_peak_positions: List[Tuple[float, Dict]] = [] + vocal_positions: List[Tuple[float, Dict]] = [] + vocal_build_positions: List[Tuple[float, Dict]] = [] + vocal_peak_positions: List[Tuple[float, Dict]] = [] + crash_positions: List[Tuple[float, Dict]] = [] + fill_positions: List[Tuple[float, Dict]] = [] + snare_roll_positions: List[Tuple[float, Dict]] = [] + atmos_positions: List[Tuple[float, Dict]] = [] + vocal_shot_positions: List[Tuple[float, Dict]] = [] + + bass_step = self._loop_step_beats(selected.get("bass_loop"), project_bpm, 16.0) + perc_step = self._loop_step_beats(selected.get("perc_loop"), project_bpm, 16.0) + perc_alt_step = self._loop_step_beats(perc_alt, project_bpm, 8.0) + top_loop_step = self._loop_step_beats(selected.get("top_loop"), project_bpm, 8.0) + synth_step = self._loop_step_beats(selected.get("synth_loop"), project_bpm, 16.0) + vocal_step = self._loop_step_beats(selected.get("vocal_loop"), project_bpm, 8.0) + vocal_alt_step = self._loop_step_beats(vocal_alt, project_bpm, 8.0) + synth_alt_step = self._loop_step_beats(synth_alt, project_bpm, 8.0) + atmos_step = self._loop_step_beats(selected.get("atmos_fx"), project_bpm, 16.0) + + # Store section-specific samples for roles eligible for variation + section_samples: Dict[int, Dict[str, Optional[Dict[str, Any]]]] = {} + + for index, (section, start, end) in enumerate(offsets): + kind = str(section.get("kind", "drop")).lower() + section_name = str(section.get("name", "")).lower() + midpoint = (start + end) / 2.0 + progress = midpoint / max(1.0, offsets[-1][2]) + energy = self._section_energy(reference, progress) + is_peak = "peak" in section_name or energy > 0.82 + is_vocal = "vocal" in section_name + span = max(4.0, end - start) + has_next_section = index < len(offsets) - 1 + next_section = offsets[index + 1][0] if has_next_section else {} + next_kind = str(next_section.get("kind", "")).lower() + next_name = str(next_section.get("name", "")).lower() + transition_into_drop = next_kind == "drop" or "drop" in next_name or "peak" in next_name + transition_is_vocal = "vocal" in next_name + tail_hit = max(start, end - min(4.0, span / 2.0)) + roll_start = max(start, end - min(8.0, span)) + + # Apply section variation for eligible roles + section_variant = self._get_section_variant(kind, section.get('name', '')) + section_samples[index] = {} + + # Map roles to their match lists and global selections + role_match_map = { + 'perc': ('perc_loop', matches.get('perc_loop', []), selected.get('perc_loop')), + 'perc_alt': ('perc_loop', matches.get('perc_loop', []), perc_alt), + 'top_loop': ('top_loop', matches.get('top_loop', []), selected.get('top_loop')), + 'vocal_shot': ('vocal_shot', matches.get('vocal_shot', []), selected.get('vocal_shot')), + 'synth_peak': ('synth_loop', matches.get('synth_loop', []), synth_alt), + 'atmos': ('atmos_fx', matches.get('atmos_fx', []), selected.get('atmos_fx')), + } + + for var_role, (match_role, match_list, fallback_sample) in role_match_map.items(): + if var_role in SECTION_VARIATION_ROLES and match_list and section_variant != 'standard': + # Apply variant filtering with section-specific samples + section_samples_map = self._get_variant_samples_for_section( + match_list, + var_role, + kind, + section.get('name', ''), + target_key=project_key, + target_bpm=project_bpm + ) + + # Get section-specific samples for this role + section_key = f"{kind}_{section.get('name', '')}" + specific_samples = section_samples_map.get(section_key, match_list) + + # Use specific_samples for selection + samples_to_use = specific_samples if specific_samples else match_list + + if samples_to_use and samples_to_use != match_list: + # Select from section-specific samples, avoiding already used paths + section_used_paths = used_paths.copy() + section_sample = self._select_distinct_candidate( + match_role, + samples_to_use, + rng, + section_used_paths, + used_families + ) + + if section_sample: + # Get the actual file path for logging + sample_path = section_sample.get('file_path', section_sample.get('file_name', 'unknown')) + logger.debug("SECTION_VARIANT_REAL: role '%s' using %d specific samples for section '%s' (vs %d base) - selected: %s", + var_role, len(samples_to_use), section.get('name'), len(match_list), sample_path) + section_samples[index][var_role] = section_sample + else: + # Fallback to global selection + section_samples[index][var_role] = fallback_sample + else: + # No filtering applied or no samples after filter, use global + section_samples[index][var_role] = fallback_sample + else: + # Not eligible for variation or no variant, use global + section_samples[index][var_role] = fallback_sample + + # Helper to get the right sample for a role in this section + def get_sample(role: str, fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Get section-specific sample if available, otherwise fallback.""" + return section_samples[index].get(role, fallback) + + atmos_sample = get_sample('atmos', selected.get("atmos_fx")) + if atmos_sample and kind in {"intro", "break", "outro"}: + add_range(atmos_positions, start, end, max(8.0, atmos_step), sample=atmos_sample) + elif atmos_sample and is_vocal and span >= 8.0: + add_hit(atmos_positions, max(start, end - 8.0), sample=atmos_sample) + + if kind == 'intro': + add_range(kick_positions, start, end, 2.0 if energy < 0.55 else 1.0, sample=selected.get("kick")) + add_range(hat_positions, start, end, 1.0, 0.5, sample=selected.get("hat")) + if selected.get("top_loop") and energy > 0.5: + add_range(top_loop_positions, start + min(4.0, span / 2.0), end, top_loop_step, 0.0, sample=get_sample('top_loop', selected.get("top_loop"))) + elif kind == 'break': + add_range(kick_positions, start, end, 4.0, sample=selected.get("kick")) + add_range(snare_positions, start + 3.0, end, 4.0, sample=selected.get("snare")) + if selected.get("perc_loop"): + perc_sample = get_sample('perc_alt', perc_alt) if perc_alt else get_sample('perc', selected.get("perc_loop")) + add_range(perc_alt_positions if perc_alt else perc_positions, start, end, perc_alt_step if perc_alt else perc_step, sample=perc_sample) + if vocal_alt and (is_vocal or energy > 0.6): + add_range(vocal_build_positions, start + max(0.0, span - 8.0), end, vocal_alt_step, sample=vocal_alt) + if selected.get("fill_fx") and has_next_section: + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + if selected.get("snare_roll") and has_next_section: + add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) + elif kind == 'build': + add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) + add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) + add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) + if selected.get("bass_loop"): + add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) + if selected.get("perc_loop"): + add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) + if selected.get("top_loop"): + add_range(top_loop_positions, start + 4.0, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) + if selected.get("vocal_loop") and is_vocal: + add_range(vocal_positions, start, end, vocal_step, sample=selected.get("vocal_loop")) + if vocal_alt and (is_vocal or energy > 0.58): + add_range(vocal_build_positions, start, end, vocal_alt_step, 0.0, sample=vocal_alt) + if selected.get("synth_loop") and energy > 0.62: + add_range(synth_positions, max(start, end - max(8.0, synth_step)), end, synth_step, sample=selected.get("synth_loop")) + if selected.get("snare_roll"): + add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) + if selected.get("fill_fx"): + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + if transition_into_drop and selected.get("crash_fx"): + add_hit(crash_positions, end, sample=selected.get("crash_fx")) + else: + add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) + add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) + add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) + if selected.get("bass_loop"): + add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) + if selected.get("perc_loop"): + add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) + if selected.get("top_loop"): + add_range(top_loop_positions, start, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) + if perc_alt and ("peak" in str(section.get("name", "")).lower() or energy > 0.82): + add_range(perc_alt_positions, start, end, perc_alt_step, sample=get_sample('perc_alt', perc_alt)) + if selected.get("synth_loop") and ("drop b" in section_name or is_peak or kind == 'drop'): + add_range(synth_positions, start, end, synth_step, sample=selected.get("synth_loop")) + if synth_alt and is_peak: + add_range(synth_peak_positions, start + min(4.0, span / 4.0), end, synth_alt_step, sample=get_sample('synth_peak', synth_alt)) + if selected.get("vocal_loop") and ("drop b" in section_name or is_peak): + add_range(vocal_positions, start + 4.0, end, vocal_step, sample=selected.get("vocal_loop")) + if vocal_alt and is_peak: + add_range(vocal_peak_positions, start, end, vocal_alt_step, sample=vocal_alt) + if selected.get("crash_fx") and index > 0: + add_hit(crash_positions, start, sample=selected.get("crash_fx")) + if selected.get("fill_fx") and has_next_section and next_kind != "outro": + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + + vocal_shot_sample = get_sample('vocal_shot', selected.get("vocal_shot")) + if vocal_shot_sample and (is_peak or transition_is_vocal): + add_hit(vocal_shot_positions, min(end - 1.0, start + 4.0), sample=vocal_shot_sample) + if span >= 16.0: + add_hit(vocal_shot_positions, min(end - 1.0, start + span / 2.0), sample=vocal_shot_sample) + + layers: List[Dict[str, Any]] = [] + + def add_layer(name: str, asset: Optional[Dict[str, Any]], positions: List[Tuple[float, Dict]], + color: int, volume: float): + """Add one or more layers for positions grouped by sample.""" + if not positions: + return + + # Group positions by sample + positions_by_sample: Dict[str, List[float]] = {} + sample_info: Dict[str, Dict[str, Any]] = {} + + for pos, sample in positions: + if sample is None: + continue + sample_path = sample.get("path", "") + if sample_path not in positions_by_sample: + positions_by_sample[sample_path] = [] + sample_info[sample_path] = sample + positions_by_sample[sample_path].append(pos) + + # If no asset provided but positions exist, use the first sample + if asset is None and positions_by_sample: + first_sample_path = next(iter(positions_by_sample)) + asset = sample_info[first_sample_path] + + # If all positions use the same sample (or asset is provided), create single layer + if asset and (len(positions_by_sample) == 1 or asset.get("path") in positions_by_sample): + asset_positions = positions_by_sample.get(asset.get("path", ""), [p for p, _ in positions]) + if asset_positions: + adj_vol = volume + rms = asset.get("rms_energy", 0.0) + if rms > 0.0: + adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) + + layers.append({ + "name": name, + "file_path": asset["path"], + "positions": sorted(set(asset_positions)), + "color": color, + "volume": round(adj_vol, 3), + "source": asset.get("file_name", ""), + }) + else: + # Multiple samples - create layers with variant names + for i, (sample_path, pos_list) in enumerate(positions_by_sample.items()): + sample = sample_info[sample_path] + variant_name = sample.get("file_name", "") + + adj_vol = volume + rms = sample.get("rms_energy", 0.0) + if rms > 0.0: + adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) + + # Create variant suffix based on sample characteristics + if i > 0: + layer_name = f"{name} ({variant_name[:20]})" + else: + layer_name = name + + layers.append({ + "name": layer_name, + "file_path": sample_path, + "positions": sorted(set(pos_list)), + "color": color, + "volume": round(adj_vol, 3), + "source": variant_name, + }) + + add_layer("AUDIO KICK", selected.get("kick"), kick_positions, 10, 0.86) + add_layer("AUDIO CLAP", selected.get("snare"), snare_positions, 45, 0.72) + add_layer("AUDIO HAT", selected.get("hat"), hat_positions, 5, 0.58) + add_layer("AUDIO BASS LOOP", selected.get("bass_loop"), bass_positions, 30, 0.76) + add_layer("AUDIO PERC MAIN", selected.get("perc_loop"), perc_positions, 20, 0.68) + add_layer("AUDIO PERC ALT", perc_alt, perc_alt_positions, 22, 0.62) + add_layer("AUDIO TOP LOOP", selected.get("top_loop") or perc_alt or selected.get("perc_loop"), top_loop_positions, 24, 0.52) + add_layer("AUDIO SYNTH LOOP", selected.get("synth_loop"), synth_positions, 50, 0.52) + add_layer("AUDIO SYNTH PEAK", synth_alt or selected.get("synth_loop"), synth_peak_positions, 52, 0.48) + add_layer("AUDIO VOCAL LOOP", selected.get("vocal_loop"), vocal_positions, 40, 0.6) + add_layer("AUDIO VOCAL BUILD", vocal_alt or selected.get("vocal_loop"), vocal_build_positions, 42, 0.54) + add_layer("AUDIO VOCAL PEAK", vocal_alt or selected.get("vocal_loop"), vocal_peak_positions, 43, 0.58) + add_layer("AUDIO CRASH FX", selected.get("crash_fx"), crash_positions, 26, 0.5) + add_layer("AUDIO TRANSITION FILL", selected.get("fill_fx") or selected.get("snare_roll"), fill_positions, 28, 0.56) + add_layer("AUDIO SNARE ROLL", selected.get("snare_roll"), snare_roll_positions, 27, 0.54) + add_layer("AUDIO ATMOS", selected.get("atmos_fx"), atmos_positions, 54, 0.44) + add_layer("AUDIO VOCAL SHOT", selected.get("vocal_shot"), vocal_shot_positions, 41, 0.52) + + # Compute remake quality metrics + remake_quality = self._compute_remake_quality_metrics( + sections, selected, sections + ) + + # Build section energy profile for generator + section_energy_profile = [] + for section in sections: + features = section.get('features', {}) + section_energy_profile.append({ + 'kind': section.get('kind', 'drop'), + 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), + 'energy_peak': features.get('energy_peak', 0.5), + 'energy_slope': features.get('energy_slope', 0.0), + 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), + 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), + 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), + 'low_energy_ratio': features.get('low_energy_ratio', 0.0), + 'high_energy_ratio': features.get('high_energy_ratio', 0.0), + 'kind_confidence': section.get('kind_confidence', 0.5), + }) + + return { + "reference": { + "path": reference.get("path"), + "file_name": reference.get("file_name"), + "tempo": reference.get("tempo"), + "key": reference.get("key") or project_key, + "device": self.device_name, + "variant_seed": variant_seed, + }, + "sections": sections, + "segment_roles": segment_roles, + "layers": layers, + "matches": selected, + "section_samples": section_samples, + "section_energy_profile": section_energy_profile, + "remake_quality": remake_quality, + } + + def _compute_remake_quality_metrics( + self, + sections: List[Dict[str, Any]], + selected: Dict[str, Optional[Dict[str, Any]]], + reference_sections: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Compute per-section quality scores for how well selected samples match reference character. + + Metrics included: + - Energy profile similarity + - Spectral characteristic similarity + - Rhythmic density comparison + - Low-end presence matching + - High-end brightness matching + + Uses already-computed data - no new librosa calls. + """ + section_scores = [] + + energy_profile_scores = [] + spectral_similarity_scores = [] + rhythmic_density_scores = [] + low_end_presence_scores = [] + high_end_brightness_scores = [] + + for i, section in enumerate(sections): + kind = str(section.get('kind', 'drop')).lower() + features = section.get('features', {}) + section_match_score = 0.5 + weak_roles = [] + + ref_energy_mean = features.get('energy_mean', features.get('energy', 0.5)) + _ = features.get('energy_peak', ref_energy_mean) + ref_energy_slope = features.get('energy_slope', 0.0) + ref_onset_rate = features.get('onset_rate', features.get('onset_density', 0.5)) + ref_low_ratio = features.get('low_energy_ratio', 0.0) + ref_high_ratio = features.get('high_energy_ratio', 0.0) + ref_spectral_centroid = features.get('spectral_centroid_mean', features.get('brightness', 0.5)) + ref_spectral_std = features.get('spectral_centroid_std', 0.0) + + energy_profile_score = 0.5 + spectral_similarity_score = 0.5 + rhythmic_density_score = 0.5 + low_end_presence_score = 0.5 + high_end_brightness_score = 0.5 + + selected_samples_energy = [] + selected_samples_centroid = [] + selected_samples_onset = [] + selected_samples_low_energy = 0.0 + selected_samples_high_energy = 0.0 + + for role in ['kick', 'snare', 'hat', 'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx']: + sample = selected.get(role) + if sample: + rms = float(sample.get('rms_mean', sample.get('rms_energy', 0.5)) or 0.5) + centroid = float(sample.get('spectral_centroid', 5000) or 5000) + onset = float(sample.get('onset_mean', sample.get('onset_rate', 3)) or 3) + + selected_samples_energy.append(rms) + selected_samples_centroid.append(centroid) + selected_samples_onset.append(onset) + + if centroid < 300: + selected_samples_low_energy += rms + if centroid > 4000: + selected_samples_high_energy += rms + + if selected_samples_energy: + avg_energy = sum(selected_samples_energy) / len(selected_samples_energy) + energy_diff = abs(avg_energy - ref_energy_mean) + energy_profile_score = max(0.0, 1.0 - energy_diff * 2.0) + + if ref_energy_slope > 0.1: + build_roles = ['snare_roll', 'fill_fx', 'hat'] + build_energy = sum( + float(selected.get(r, {}).get('rms_mean', 0) or 0) + for r in build_roles if selected.get(r) + ) + if build_energy > 0.3: + energy_profile_score = min(1.0, energy_profile_score + 0.15) + + if selected_samples_centroid: + avg_centroid_norm = sum(selected_samples_centroid) / len(selected_samples_centroid) / 10000.0 + ref_centroid_norm = ref_spectral_centroid + centroid_diff = abs(avg_centroid_norm - ref_centroid_norm) + spectral_similarity_score = max(0.0, 1.0 - centroid_diff) + + if ref_spectral_std > 0.3: + centroid_variance = 0.0 + if len(selected_samples_centroid) > 1: + centroid_variance = float(np.std(selected_samples_centroid)) / 10000.0 + if centroid_variance > 0.1: + spectral_similarity_score = min(1.0, spectral_similarity_score + 0.1) + + if selected_samples_onset: + avg_onset_norm = sum(selected_samples_onset) / len(selected_samples_onset) / 10.0 + ref_onset_norm = ref_onset_rate + onset_diff = abs(avg_onset_norm - ref_onset_norm) + rhythmic_density_score = max(0.0, 1.0 - onset_diff) + + if ref_onset_rate > 0.5: + perc_onset = float(selected.get('perc_loop', {}).get('onset_mean', 0) or 0) + top_onset = float(selected.get('top_loop', {}).get('onset_mean', 0) or 0) + hat_onset = float(selected.get('hat', {}).get('onset_mean', 0) or 0) + if perc_onset > 3 or top_onset > 3 or hat_onset > 3: + rhythmic_density_score = min(1.0, rhythmic_density_score + 0.15) + + bass_match = selected.get('bass_loop') + kick_match = selected.get('kick') + if bass_match or kick_match: + bass_centroid = float(bass_match.get('spectral_centroid', 500) or 500) if bass_match else 500 + kick_centroid = float(kick_match.get('spectral_centroid', 300) or 300) if kick_match else 300 + low_centroid_avg = (bass_centroid + kick_centroid) / 2 + + if ref_low_ratio > 0.3: + if low_centroid_avg < 1500: + low_end_presence_score = 0.85 + (ref_low_ratio * 0.15) + elif low_centroid_avg < 2500: + low_end_presence_score = 0.65 + else: + low_end_presence_score = 0.35 + weak_roles.append('bass_loop') + else: + low_end_presence_score = 0.7 + else: + if ref_low_ratio > 0.35: + low_end_presence_score = 0.3 + weak_roles.append('bass_loop') + + hat_match = selected.get('hat') + top_match = selected.get('top_loop') + synth_match = selected.get('synth_loop') + if hat_match or top_match or synth_match: + high_centroids = [] + if hat_match: + high_centroids.append(float(hat_match.get('spectral_centroid', 6000) or 6000)) + if top_match: + high_centroids.append(float(top_match.get('spectral_centroid', 5000) or 5000)) + if synth_match: + high_centroids.append(float(synth_match.get('spectral_centroid', 4000) or 4000)) + + avg_high_centroid = sum(high_centroids) / len(high_centroids) if high_centroids else 5000 + + if ref_high_ratio > 0.25: + if avg_high_centroid > 7000: + high_end_brightness_score = 0.85 + (ref_high_ratio * 0.15) + elif avg_high_centroid > 5000: + high_end_brightness_score = 0.65 + else: + high_end_brightness_score = 0.4 + weak_roles.append('hat') + else: + high_end_brightness_score = 0.7 + else: + if ref_high_ratio > 0.3: + high_end_brightness_score = 0.35 + weak_roles.append('hat') + + if kind == 'drop': + if bass_match and ref_energy_mean > 0.6: + section_match_score += 0.08 + if hat_match and ref_onset_rate > 0.4: + section_match_score += 0.05 + elif kind == 'break': + atmos_match = selected.get('atmos_fx') + if atmos_match and ref_energy_mean < 0.45: + section_match_score += 0.10 + low_end_presence_score = min(1.0, low_end_presence_score + 0.1) + elif kind == 'build': + snare_roll_match = selected.get('snare_roll') + fill_match = selected.get('fill_fx') + if snare_roll_match and ref_energy_slope > 0.05: + section_match_score += 0.08 + rhythmic_density_score = min(1.0, rhythmic_density_score + 0.1) + if fill_match: + section_match_score += 0.05 + elif kind == 'intro': + atmos_match = selected.get('atmos_fx') + if atmos_match: + section_match_score += 0.05 + elif kind == 'outro': + atmos_match = selected.get('atmos_fx') + if atmos_match and ref_energy_mean < 0.4: + section_match_score += 0.05 + + energy_profile_scores.append(energy_profile_score) + spectral_similarity_scores.append(spectral_similarity_score) + rhythmic_density_scores.append(rhythmic_density_score) + low_end_presence_scores.append(low_end_presence_score) + high_end_brightness_scores.append(high_end_brightness_score) + + combined_score = ( + energy_profile_score * 0.20 + + spectral_similarity_score * 0.20 + + rhythmic_density_score * 0.20 + + low_end_presence_score * 0.20 + + high_end_brightness_score * 0.20 + ) + section_match_score = max(section_match_score, combined_score) + section_match_score = max(0.0, min(1.0, section_match_score)) + + section_scores.append({ + 'kind': kind, + 'score': round(section_match_score, 3), + 'weak_roles': weak_roles, + 'energy_profile_score': round(energy_profile_score, 3), + 'spectral_similarity_score': round(spectral_similarity_score, 3), + 'rhythmic_density_score': round(rhythmic_density_score, 3), + 'low_end_presence_score': round(low_end_presence_score, 3), + 'high_end_brightness_score': round(high_end_brightness_score, 3), + }) + + overall_score = sum(s['score'] for s in section_scores) / max(len(section_scores), 1) + + avg_energy_profile = sum(energy_profile_scores) / max(len(energy_profile_scores), 1) + avg_spectral = sum(spectral_similarity_scores) / max(len(spectral_similarity_scores), 1) + avg_rhythmic = sum(rhythmic_density_scores) / max(len(rhythmic_density_scores), 1) + avg_low_end = sum(low_end_presence_scores) / max(len(low_end_presence_scores), 1) + avg_high_end = sum(high_end_brightness_scores) / max(len(high_end_brightness_scores), 1) + + improvement_hints = [] + for section_score in section_scores: + for role in section_score.get('weak_roles', []): + hint = f"{section_score['kind']} section needs better {role} samples" + if hint not in improvement_hints: + improvement_hints.append(hint) + + if avg_energy_profile < 0.5: + improvement_hints.append("Overall energy profile mismatch - adjust sample dynamics") + if avg_spectral < 0.5: + improvement_hints.append("Spectral characteristics differ - check brightness/texture match") + if avg_rhythmic < 0.5: + improvement_hints.append("Rhythmic density mismatch - adjust percussive element selection") + if avg_low_end < 0.5: + improvement_hints.append("Low-end presence weak - select bass/kick with more sub energy") + if avg_high_end < 0.5: + improvement_hints.append("High-end brightness lacking - select brighter hat/top samples") + + return { + 'remake_score': round(overall_score, 3), + 'section_scores': [ + { + 'kind': s['kind'], + 'score': s['score'], + 'weak_roles': s['weak_roles'], + } + for s in section_scores + ], + 'improvement_hints': improvement_hints[:10], + 'metric_averages': { + 'energy_similarity': round(avg_energy_profile, 3), + 'spectral_similarity': round(avg_spectral, 3), + 'rhythmic_density': round(avg_rhythmic, 3), + 'low_end_match': round(avg_low_end, 3), + 'high_end_match': round(avg_high_end, 3), + }, + 'metrics_detail': { + 'energy_similarity': { + 'description': 'RMS energy distribution comparison between selected samples and reference section energy', + 'range': '0.0-1.0, higher is better', + 'weight': 0.22, + 'factors': ['average RMS match', 'energy slope for builds', 'peak energy variance'], + }, + 'spectral_similarity': { + 'description': 'Spectral centroid and variance matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.18, + 'factors': ['centroid mean match', 'centroid variance match'], + }, + 'rhythmic_density': { + 'description': 'Onset rate comparison between selected samples and reference', + 'range': '0.0-1.0, higher is better', + 'weight': 0.22, + 'factors': ['onset rate match', 'percussive element density'], + }, + 'low_end_match': { + 'description': 'Sub-bass and low frequency content presence matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.20, + 'factors': ['bass spectral centroid', 'kick spectral centroid', 'low frequency RMS'], + }, + 'high_end_match': { + 'description': 'High frequency brightness and air content matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.18, + 'factors': ['hat spectral centroid', 'top loop brightness', 'synth high frequency content'], + }, + }, + } + + +def export_segment_rag_manifest( + manifest: List[Dict[str, Any]], + output_path: Path, + format: str = "json", +) -> None: + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if format == "json": + output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return + + cached = [item for item in manifest if item.get("cached")] + built = [item for item in manifest if not item.get("cached")] + total_cached_segments = sum(int(item.get("segments", 0) or 0) for item in cached) + total_built_segments = sum(int(item.get("segments", 0) or 0) for item in built) + + lines = [ + "# Segment RAG Index Manifest", + "", + f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}", + f"Total Files: {len(manifest)}", + "", + "## Summary", + "", + f"- Cached (reused): {len(cached)}", + f"- Built (analyzed): {len(built)}", + f"- Cached segments: {total_cached_segments}", + f"- Built segments: {total_built_segments}", + ] + + if cached: + lines.extend(["", "## Cached Files (Reused)", ""]) + for item in sorted(cached, key=lambda value: value.get("file_name", "").lower()): + lines.append(f"- **{item.get('file_name', 'unknown')}**") + lines.append(f" - Roles: {', '.join(item.get('roles', []))}") + lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") + + if built: + lines.extend(["", "## Built Files (Analyzed)", ""]) + for item in sorted(built, key=lambda value: value.get("file_name", "").lower()): + lines.append(f"- **{item.get('file_name', 'unknown')}**") + lines.append(f" - Roles: {', '.join(item.get('roles', []))}") + lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") + + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _get_segment_rag_status(library_dir: Path) -> Dict[str, Any]: + """ + Get status of the segment RAG cache with human-readable metadata. + + For each cache file, tries to recover metadata from: + 1. Embedded metadata in the cache file (new format) + 2. indexing_state.json lookup (backfill source) + + Returns stats about metadata coverage and human-readable names. + """ + cache_dir = library_dir / ".segment_rag" + + if not cache_dir.exists(): + return { + "cache_dir": str(cache_dir), + "cache_files": 0, + "total_segments": 0, + "status": "not_built" + } + + cache_files = list(cache_dir.glob("*.json.gz")) + total_segments = 0 + role_coverage: Dict[str, int] = defaultdict(int) + entries: List[Dict[str, Any]] = [] + state_path = cache_dir / "indexing_state.json" + state_payload: Dict[str, Any] = {} + if state_path.exists(): + try: + state_payload = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + state_payload = {} + indexed_entries = state_payload.get("indexed_entries", {}) or {} + + # Build lookup by cache_prefix for state entries + by_prefix: Dict[str, Dict[str, Any]] = {} + for entry in indexed_entries.values(): + cache_prefix = entry.get("cache_prefix") + if cache_prefix: + by_prefix[cache_prefix] = entry + + # Track metadata coverage + files_with_embedded_metadata = 0 + files_with_state_metadata = 0 + files_without_metadata = 0 + + for cache_file in cache_files: + try: + with gzip.open(cache_file, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + if isinstance(payload, list): + segments = payload + metadata = {} + elif isinstance(payload, dict): + segments = payload.get("segments", []) or [] + metadata = payload.get("metadata", {}) or {} + else: + segments = [] + metadata = {} + total_segments += len(segments) + + # Extract cache prefix from filename (format: {path_key}__{fingerprint}__{windows}__{duration}.json.gz) + cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem + cache_prefix = cache_stem.rsplit("__", 1)[0] + + # Look up metadata from state file + state_entry = by_prefix.get(cache_prefix, {}) + + # Determine metadata source + has_embedded = bool(metadata) + has_state = bool(state_entry) + + if has_embedded: + files_with_embedded_metadata += 1 + elif has_state: + files_with_state_metadata += 1 + else: + files_without_metadata += 1 + + # Merge metadata: prefer embedded, fallback to state + file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_file.name + file_path = metadata.get("path") or state_entry.get("path") or "" + roles = metadata.get("roles") or state_entry.get("roles") or [] + + # Determine if the name is human-readable (not just a hash) + is_hash_name = len(cache_stem.split("__")[0]) == 16 and all(c in "0123456789abcdef" for c in cache_stem.split("__")[0]) + has_human_name = file_name != cache_file.name and not is_hash_name + + for role in roles: + if role: + role_coverage[role] += len(segments) + + mtime = cache_file.stat().st_mtime + + entries.append({ + "file_name": file_name, + "path": file_path, + "segments": len(segments), + "mtime": mtime, + "cache_file": cache_file.name, + "roles": roles, + "has_embedded_metadata": has_embedded, + "has_human_readable_name": has_human_name, + }) + except Exception: + logger.debug("Failed to inspect segment cache %s", cache_file, exc_info=True) + + entries.sort(key=lambda item: item["mtime"], reverse=True) + + # Calculate cache size + cache_size_bytes = sum(f.stat().st_size for f in cache_files) + cache_size_mb = round(cache_size_bytes / (1024 * 1024), 2) + + return { + "cache_dir": str(cache_dir), + "cache_files": len(cache_files), + "total_segments": total_segments, + "role_coverage": dict(role_coverage) if role_coverage else {}, + "newest_entries": entries[:5], + "oldest_entries": entries[-5:] if len(entries) > 5 else [], + "metadata_coverage": { + "files_with_embedded_metadata": files_with_embedded_metadata, + "files_with_state_metadata": files_with_state_metadata, + "files_without_metadata": files_without_metadata, + }, + "cache_size_mb": cache_size_mb, + "indexing_complete": state_payload.get("complete", False), + "last_indexed": state_payload.get("timestamp"), + "status": "ok" + } + + +def _backfill_segment_cache_metadata(library_dir: Path, force: bool = False) -> Dict[str, Any]: + """ + Backfill metadata into existing segment cache files. + + For cache files that don't have embedded metadata, this function: + 1. Looks up the file in indexing_state.json + 2. Rewrites the cache file with metadata included + + Args: + library_dir: Path to the audio library + force: If True, rewrite all cache files even if they already have metadata + + Returns: + Dict with backfill statistics + """ + cache_dir = library_dir / ".segment_rag" + + if not cache_dir.exists(): + return { + "cache_dir": str(cache_dir), + "backfilled": 0, + "skipped": 0, + "errors": 0, + "status": "no_cache" + } + + # Load state file for metadata lookup + state_path = cache_dir / "indexing_state.json" + state_payload: Dict[str, Any] = {} + if state_path.exists(): + try: + state_payload = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + state_payload = {} + + indexed_entries = state_payload.get("indexed_entries", {}) or {} + + # Build lookup by cache_prefix + by_prefix: Dict[str, Dict[str, Any]] = {} + for entry in indexed_entries.values(): + cache_prefix = entry.get("cache_prefix") + if cache_prefix: + by_prefix[cache_prefix] = entry + + cache_files = list(cache_dir.glob("*.json.gz")) + backfilled = 0 + skipped = 0 + errors = 0 + + for cache_file in cache_files: + try: + with gzip.open(cache_file, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + + # Check if already has metadata + if isinstance(payload, dict): + segments = payload.get("segments", []) or [] + metadata = payload.get("metadata", {}) or {} + if metadata and not force: + skipped += 1 + continue + elif isinstance(payload, list): + segments = payload + metadata = {} + else: + continue + + # Extract cache prefix from filename + cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem + cache_prefix = cache_stem.rsplit("__", 1)[0] + + # Look up metadata from state + state_entry = by_prefix.get(cache_prefix, {}) + file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_stem + roles = metadata.get("roles") or state_entry.get("roles") or [] + path = metadata.get("path") or state_entry.get("path") or "" + + # If we found some metadata, rewrite the cache file + if file_name or roles or path: + new_metadata = { + "file_name": file_name, + "path": path, + "roles": roles, + "indexed_at": time.time(), + "backfilled": True, + } + new_payload = { + "segments": segments, + "metadata": new_metadata + } + with gzip.open(cache_file, "wt", encoding="utf-8") as handle: + json.dump(new_payload, handle) + backfilled += 1 + logger.debug("Backfilled metadata for %s", cache_file.name) + else: + skipped += 1 + + except Exception: + errors += 1 + logger.debug("Failed to backfill %s", cache_file, exc_info=True) + + return { + "cache_dir": str(cache_dir), + "cache_files": len(cache_files), + "backfilled": backfilled, + "skipped": skipped, + "errors": errors, + "status": "ok" + } diff --git a/AbletonMCP_AI/MCP_Server/reference_stem_builder.py b/AbletonMCP_AI/MCP_Server/reference_stem_builder.py new file mode 100644 index 0000000..fb1a15e --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/reference_stem_builder.py @@ -0,0 +1,264 @@ +""" +reference_stem_builder.py - Rebuild an Ableton arrangement directly from a reference track. +""" + +from __future__ import annotations + +import json +import logging +import socket +from pathlib import Path +from typing import Any, Dict, List, Tuple + +import soundfile as sf +import torch +from demucs.apply import apply_model +from demucs.pretrained import get_model + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + from reference_listener import ReferenceAudioListener +except ImportError: # pragma: no cover + from .reference_listener import ReferenceAudioListener + + +logger = logging.getLogger("ReferenceStemBuilder") + +HOST = "127.0.0.1" +PORT = 9877 +MESSAGE_TERMINATOR = b"\n" +SCRIPT_DIR = Path(__file__).resolve().parent +PACKAGE_DIR = SCRIPT_DIR.parent +PROJECT_SAMPLES_DIR = PACKAGE_DIR.parent / "librerias" / "organized_samples" +SAMPLES_DIR = str(PROJECT_SAMPLES_DIR) + +TRACK_LAYOUT = ( + ("REFERENCE FULL", 59, 0.72, True), + ("REF DRUMS", 10, 0.84, False), + ("REF BASS", 30, 0.82, False), + ("REF OTHER", 50, 0.68, False), + ("REF VOCALS", 40, 0.70, False), +) + +SECTION_BLUEPRINTS = { + "club": [ + ("INTRO DJ", 16), + ("GROOVE A", 16), + ("VOCAL BUILD", 8), + ("DROP A", 16), + ("BREAKDOWN", 8), + ("BUILD B", 8), + ("DROP B", 16), + ("PEAK", 8), + ("OUTRO DJ", 16), + ], + "standard": [ + ("INTRO", 8), + ("BUILD", 8), + ("DROP A", 16), + ("BREAK", 8), + ("DROP B", 16), + ("OUTRO", 8), + ], +} + + +class AbletonSocketClient: + def __init__(self, host: str = HOST, port: int = PORT): + self.host = host + self.port = port + + def send(self, command_type: str, params: Dict[str, Any] | None = None, timeout: float = 30.0) -> Dict[str, Any]: + payload = json.dumps({"type": command_type, "params": params or {}}, separators=(",", ":")).encode("utf-8") + MESSAGE_TERMINATOR + with socket.create_connection((self.host, self.port), timeout=timeout) as sock: + sock.sendall(payload) + data = b"" + while not data.endswith(MESSAGE_TERMINATOR): + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + if not data: + raise RuntimeError(f"Sin respuesta para {command_type}") + return json.loads(data.decode("utf-8", errors="replace").strip()) + + +def _resolve_reference_profile(reference_path: Path) -> Dict[str, Any]: + listener = ReferenceAudioListener(SAMPLES_DIR) + analysis = listener.analyze_reference(str(reference_path)) + structure = "club" if analysis.get("duration", 0.0) >= 180 else "standard" + return { + "tempo": float(analysis.get("tempo", 128.0) or 128.0), + "key": str(analysis.get("key", "") or ""), + "duration": float(analysis.get("duration", 0.0) or 0.0), + "structure": structure, + "listener_device": analysis.get("device", "cpu"), + } + + +def ensure_reference_wav(reference_path: Path) -> Path: + if reference_path.suffix.lower() == ".wav": + return reference_path + + if librosa is None: + raise RuntimeError("librosa no está disponible para convertir la referencia a WAV") + + wav_path = reference_path.with_suffix(".wav") + if wav_path.exists() and wav_path.stat().st_size > 0: + return wav_path + + y, sr = librosa.load(str(reference_path), sr=44100, mono=False) + if y.ndim == 1: + y = y.reshape(1, -1) + sf.write(str(wav_path), y.T, sr, subtype="PCM_16") + return wav_path + + +def separate_stems(reference_wav: Path, output_dir: Path) -> Dict[str, Path]: + output_dir.mkdir(parents=True, exist_ok=True) + stem_root = output_dir / reference_wav.stem + expected = { + "reference": reference_wav, + "drums": stem_root / "drums.wav", + "bass": stem_root / "bass.wav", + "other": stem_root / "other.wav", + "vocals": stem_root / "vocals.wav", + } + if all(path.exists() and path.stat().st_size > 0 for path in expected.values()): + return expected + + audio, sr = sf.read(str(reference_wav), always_2d=True) + if sr != 44100: + raise RuntimeError(f"Sample rate inesperado en referencia WAV: {sr}") + + model = get_model("htdemucs") + model.cpu() + model.eval() + waveform = torch.tensor(audio.T, dtype=torch.float32) + separated = apply_model(model, waveform[None], device="cpu", progress=False)[0] + + stem_root.mkdir(parents=True, exist_ok=True) + for stem_name, tensor in zip(model.sources, separated): + stem_path = stem_root / f"{stem_name}.wav" + sf.write(str(stem_path), tensor.detach().cpu().numpy().T, sr, subtype="PCM_16") + + return expected + + +def _sections_for_structure(structure: str) -> List[Tuple[str, int]]: + return list(SECTION_BLUEPRINTS.get(structure.lower(), SECTION_BLUEPRINTS["standard"])) + + +def _create_track(client: AbletonSocketClient, name: str, color: int, volume: float) -> int: + response = client.send("create_track", {"type": "audio", "index": -1}) + if response.get("status") != "success": + raise RuntimeError(response.get("message", f"No se pudo crear {name}")) + track_index = int(response.get("result", {}).get("index")) + client.send("set_track_name", {"index": track_index, "name": name}) + client.send("set_track_color", {"index": track_index, "color": color}) + client.send("set_track_volume", {"index": track_index, "volume": volume}) + return track_index + + +def _import_full_length_audio(client: AbletonSocketClient, track_index: int, file_path: Path, name: str) -> None: + response = client.send("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": str(file_path), + "positions": [0.0], + "name": name, + }, timeout=120.0) + if response.get("status") != "success": + raise RuntimeError(response.get("message", f"No se pudo importar {name}")) + + +def _prepare_navigation_scenes(client: AbletonSocketClient, structure: str) -> None: + sections = _sections_for_structure(structure) + session_info = client.send("get_session_info") + if session_info.get("status") != "success": + return + + scene_count = int(session_info.get("result", {}).get("num_scenes", 0) or 0) + target_count = len(sections) + + while scene_count < target_count: + create_response = client.send("create_scene", {"index": -1}) + if create_response.get("status") != "success": + break + scene_count += 1 + + while scene_count > target_count and scene_count > 1: + delete_response = client.send("delete_scene", {"index": scene_count - 1}) + if delete_response.get("status") != "success": + break + scene_count -= 1 + + for scene_index, (section_name, _) in enumerate(sections): + client.send("set_scene_name", {"index": scene_index, "name": section_name}) + + +def rebuild_project_from_reference(reference_path: Path) -> Dict[str, Any]: + reference_path = reference_path.resolve() + if not reference_path.exists(): + raise FileNotFoundError(reference_path) + + profile = _resolve_reference_profile(reference_path) + reference_wav = ensure_reference_wav(reference_path) + stems = separate_stems(reference_wav, reference_path.parent / "stems") + + client = AbletonSocketClient() + clear_response = client.send("clear_project", {"keep_tracks": 0}, timeout=120.0) + if clear_response.get("status") != "success": + raise RuntimeError(clear_response.get("message", "No se pudo limpiar el proyecto")) + + client.send("stop", {}) + client.send("set_tempo", {"tempo": round(profile["tempo"], 3)}) + client.send("show_arrangement_view", {}) + client.send("jump_to", {"time": 0}) + + created = [] + for (track_name, color, volume, muted), stem_key in zip(TRACK_LAYOUT, ("reference", "drums", "bass", "other", "vocals")): + track_index = _create_track(client, track_name, color, volume) + _import_full_length_audio(client, track_index, stems[stem_key], track_name) + if muted: + client.send("set_track_mute", {"index": track_index, "mute": True}) + created.append({ + "track_index": track_index, + "name": track_name, + "file_path": str(stems[stem_key]), + }) + + _prepare_navigation_scenes(client, profile["structure"]) + client.send("loop_selection", {"start": 0, "length": max(32.0, round(profile["duration"] * profile["tempo"] / 60.0, 3)), "enable": False}) + client.send("jump_to", {"time": 0}) + client.send("show_arrangement_view", {}) + + session_info = client.send("get_session_info") + return { + "reference": str(reference_path), + "tempo": profile["tempo"], + "key": profile["key"], + "structure": profile["structure"], + "listener_device": profile["listener_device"], + "stems": created, + "session_info": session_info.get("result", {}), + } + + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser(description="Rebuild an Ableton project directly from a reference track.") + parser.add_argument("reference_path", help="Absolute or relative path to the reference audio file") + args = parser.parse_args() + + result = rebuild_project_from_reference(Path(args.reference_path)) + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI/MCP_Server/requirements.txt b/AbletonMCP_AI/MCP_Server/requirements.txt new file mode 100644 index 0000000..cf2a8b2 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/requirements.txt @@ -0,0 +1,13 @@ +# Dependencias de AbletonMCP-AI Server +# Instalar con: pip install -r requirements.txt + +mcp>=1.0.0 +# Servidor MCP FastMCP + +# Opcional: para análisis de audio avanzado +# numpy>=1.24.0 +# librosa>=0.10.0 + +# Opcional: para procesamiento con GPU AMD +# torch==2.4.1 +# torch-directml>=0.2.5 diff --git a/AbletonMCP_AI/MCP_Server/retrieval_benchmark.py b/AbletonMCP_AI/MCP_Server/retrieval_benchmark.py new file mode 100644 index 0000000..5224785 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/retrieval_benchmark.py @@ -0,0 +1,525 @@ +""" +retrieval_benchmark.py - Offline benchmark harness for retrieval quality inspection. + +Analyzes reference tracks and outputs top-N candidates per role to help spot +role contamination and evaluate retrieval quality. + +Usage: + python retrieval_benchmark.py --reference "path/to/track.mp3" + python retrieval_benchmark.py --reference "track1.mp3" "track2.mp3" --top-n 10 + python retrieval_benchmark.py --reference "track.mp3" --output results.json --format json + python retrieval_benchmark.py --reference "track.mp3" --output results.md --format markdown +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +import time +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Add parent directory to path for imports when running as script +sys.path.insert(0, str(Path(__file__).parent)) + +from reference_listener import ReferenceAudioListener, ROLE_SEGMENT_SETTINGS + +logger = logging.getLogger(__name__) + + +def _default_library_dir() -> Path: + """Get the default library directory.""" + return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" + + +def run_benchmark( + reference_paths: List[str], + library_dir: Path, + top_n: int = 10, + roles: Optional[List[str]] = None, + duration_limit: Optional[float] = None, +) -> Dict[str, Any]: + """ + Run retrieval benchmark on one or more reference tracks. + + Args: + reference_paths: List of paths to reference audio files + library_dir: Path to the sample library + top_n: Number of top candidates to show per role + roles: Optional list of specific roles to analyze + duration_limit: Optional duration limit for analysis + + Returns: + Dict containing benchmark results for each reference + """ + listener = ReferenceAudioListener(str(library_dir)) + + all_roles = list(ROLE_SEGMENT_SETTINGS.keys()) + target_roles = [r for r in (roles or all_roles) if r in all_roles] + + results = { + "benchmark_info": { + "library_dir": str(library_dir), + "top_n": top_n, + "roles": target_roles, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "device": listener.device_name, + }, + "references": [], + } + + for ref_path in reference_paths: + ref_path = Path(ref_path) + if not ref_path.exists(): + logger.warning("Reference file not found: %s", ref_path) + continue + + logger.info("Analyzing reference: %s", ref_path.name) + + try: + start_time = time.time() + + # Run match_assets to get candidates per role + match_result = listener.match_assets(str(ref_path)) + reference_info = match_result.get("reference", {}) + matches = match_result.get("matches", {}) + + elapsed = time.time() - start_time + + ref_result = { + "file_name": ref_path.name, + "path": str(ref_path), + "analysis_time_seconds": round(elapsed, 2), + "reference_info": { + "tempo": reference_info.get("tempo"), + "key": reference_info.get("key"), + "duration": reference_info.get("duration"), + "rms_mean": reference_info.get("rms_mean"), + "onset_mean": reference_info.get("onset_mean"), + "spectral_centroid": reference_info.get("spectral_centroid"), + }, + "sections": [ + { + "kind": s.get("kind"), + "start": s.get("start"), + "end": s.get("end"), + "bars": s.get("bars"), + } + for s in match_result.get("reference_sections", []) + ], + "role_candidates": {}, + } + + # Process each role + for role in target_roles: + role_matches = matches.get(role, []) + top_candidates = role_matches[:top_n] + + ref_result["role_candidates"][role] = { + "total_available": len(role_matches), + "top_candidates": [ + { + "rank": i + 1, + "file_name": c.get("file_name"), + "path": c.get("path"), + "score": c.get("score"), + "cosine": c.get("cosine"), + "segment_score": c.get("segment_score"), + "catalog_score": c.get("catalog_score"), + "tempo": c.get("tempo"), + "key": c.get("key"), + "duration": c.get("duration"), + } + for i, c in enumerate(top_candidates) + ], + } + + results["references"].append(ref_result) + logger.info("Completed analysis in %.2fs", elapsed) + + except Exception as e: + logger.error("Failed to analyze %s: %s", ref_path, e, exc_info=True) + results["references"].append({ + "file_name": ref_path.name, + "path": str(ref_path), + "error": str(e), + }) + + return results + + +def analyze_role_contamination(results: Dict[str, Any]) -> Dict[str, Any]: + """ + Analyze results for potential role contamination issues. + + Returns a dict with contamination analysis: + - files appearing in multiple roles + - misnamed files (e.g., "bass" appearing in "kick" role) + - score distribution anomalies + """ + contamination = { + "cross_role_files": [], + "potential_mismatches": [], + "role_score_stats": {}, + } + + # Track files appearing in multiple roles + file_to_roles: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + + for ref in results.get("references", []): + ref_name = ref.get("file_name", "unknown") + + for role, role_data in ref.get("role_candidates", {}).items(): + for candidate in role_data.get("top_candidates", []): + file_name = candidate.get("file_name", "") + if file_name: + file_to_roles[file_name].append({ + "reference": ref_name, + "role": role, + "rank": candidate.get("rank"), + "score": candidate.get("score"), + }) + + # Find files appearing in multiple roles + for file_name, appearances in file_to_roles.items(): + unique_roles = set(a["role"] for a in appearances) + if len(unique_roles) > 1: + contamination["cross_role_files"].append({ + "file_name": file_name, + "roles": list(unique_roles), + "appearances": appearances, + }) + + # Check for potential mismatches (filename suggests different role) + role_keywords = { + "kick": ["kick"], + "snare": ["snare", "clap"], + "hat": ["hat", "hihat", "hi-hat"], + "bass_loop": ["bass", "sub", "808"], + "perc_loop": ["perc", "percussion", "conga", "bongo"], + "top_loop": ["top", "drum loop", "full drum"], + "synth_loop": ["synth", "lead", "pad", "chord", "arp"], + "vocal_loop": ["vocal", "vox", "acapella"], + "crash_fx": ["crash", "cymbal", "impact"], + "fill_fx": ["fill", "transition", "tom"], + "snare_roll": ["roll", "snareroll"], + "atmos_fx": ["atmos", "drone", "ambient", "texture"], + "vocal_shot": ["shot", "vocal shot", "chop"], + } + + for ref in results.get("references", []): + for role, role_data in ref.get("role_candidates", {}).items(): + for candidate in role_data.get("top_candidates", []): + file_name = candidate.get("file_name", "").lower() + if not file_name: + continue + + # Check if file name suggests a different role + expected_keywords = role_keywords.get(role, []) + other_role_matches = [] + + for other_role, keywords in role_keywords.items(): + if other_role == role: + continue + if any(kw in file_name for kw in keywords): + other_role_matches.append(other_role) + + if other_role_matches and expected_keywords: + # File name matches another role but not this one + if not any(kw in file_name for kw in expected_keywords): + contamination["potential_mismatches"].append({ + "file_name": candidate.get("file_name"), + "assigned_role": role, + "rank": candidate.get("rank"), + "score": candidate.get("score"), + "suggested_roles": other_role_matches, + }) + + # Calculate score distribution per role + for ref in results.get("references", []): + for role, role_data in ref.get("role_candidates", {}).items(): + scores = [ + c.get("score", 0) + for c in role_data.get("top_candidates", []) + if c.get("score") is not None + ] + + if scores: + contamination["role_score_stats"][role] = { + "min": round(min(scores), 4), + "max": round(max(scores), 4), + "avg": round(sum(scores) / len(scores), 4), + "count": len(scores), + } + + return contamination + + +def format_output_json(results: Dict[str, Any]) -> str: + """Format results as JSON string.""" + return json.dumps(results, indent=2, ensure_ascii=False) + + +def format_output_markdown(results: Dict[str, Any]) -> str: + """Format results as markdown string.""" + lines = [] + + # Header + lines.append("# Retrieval Benchmark Report") + lines.append("") + lines.append(f"**Generated:** {results['benchmark_info']['timestamp']}") + lines.append(f"**Library:** `{results['benchmark_info']['library_dir']}`") + lines.append(f"**Top N:** {results['benchmark_info']['top_n']}") + lines.append(f"**Device:** {results['benchmark_info']['device']}") + lines.append("") + + # Process each reference + for ref in results.get("references", []): + lines.append(f"## Reference: {ref.get('file_name', 'unknown')}") + lines.append("") + + # Error case + if "error" in ref: + lines.append(f"**Error:** {ref['error']}") + lines.append("") + continue + + # Reference info + ref_info = ref.get("reference_info", {}) + lines.append("### Reference Analysis") + lines.append("") + lines.append("| Property | Value |") + lines.append("|----------|-------|") + lines.append(f"| Tempo | {ref_info.get('tempo', 'N/A')} BPM |") + lines.append(f"| Key | {ref_info.get('key', 'N/A')} |") + lines.append(f"| Duration | {ref_info.get('duration', 'N/A')}s |") + lines.append(f"| RMS Mean | {ref_info.get('rms_mean', 'N/A')} |") + lines.append(f"| Onset Mean | {ref_info.get('onset_mean', 'N/A')} |") + lines.append(f"| Spectral Centroid | {ref_info.get('spectral_centroid', 'N/A')} Hz |") + lines.append("") + + # Sections + sections = ref.get("sections", []) + if sections: + lines.append("### Detected Sections") + lines.append("") + lines.append("| Type | Start | End | Bars |") + lines.append("|------|-------|-----|------|") + for s in sections: + lines.append(f"| {s.get('kind', 'N/A')} | {s.get('start', 'N/A')}s | {s.get('end', 'N/A')}s | {s.get('bars', 'N/A')} |") + lines.append("") + + # Role candidates + lines.append("### Top Candidates per Role") + lines.append("") + + for role, role_data in ref.get("role_candidates", {}).items(): + total = role_data.get("total_available", 0) + lines.append(f"#### {role} ({total} available)") + lines.append("") + + candidates = role_data.get("top_candidates", []) + if not candidates: + lines.append("*No candidates found*") + lines.append("") + continue + + lines.append("| Rank | File | Score | Cosine | Seg | Catalog | Tempo | Key | Duration |") + lines.append("|------|------|-------|--------|-----|---------|-------|-----|----------|") + + for c in candidates: + lines.append( + f"| {c.get('rank', 'N/A')} | " + f"`{c.get('file_name', 'N/A')[:40]}` | " + f"{c.get('score', 0):.4f} | " + f"{c.get('cosine', 0):.4f} | " + f"{c.get('segment_score', 0):.4f} | " + f"{c.get('catalog_score', 0):.4f} | " + f"{c.get('tempo', 'N/A')} | " + f"{c.get('key', 'N/A')} | " + f"{c.get('duration', 'N/A'):.2f}s |" + ) + lines.append("") + + # Contamination analysis + if "contamination_analysis" in results: + contam = results["contamination_analysis"] + lines.append("## Role Contamination Analysis") + lines.append("") + + # Cross-role files + cross_role = contam.get("cross_role_files", []) + if cross_role: + lines.append("### Files Appearing in Multiple Roles") + lines.append("") + for item in cross_role: + lines.append(f"- **{item['file_name']}**") + lines.append(f" - Roles: {', '.join(item['roles'])}") + for app in item["appearances"]: + lines.append(f" - {app['role']}: rank {app['rank']}, score {app['score']:.4f}") + lines.append("") + + # Potential mismatches + mismatches = contam.get("potential_mismatches", []) + if mismatches: + lines.append("### Potential Role Mismatches") + lines.append("") + lines.append("Files whose names suggest a different role than assigned:") + lines.append("") + for item in mismatches: + lines.append(f"- **{item['file_name']}**") + lines.append(f" - Assigned: {item['assigned_role']} (rank {item['rank']}, score {item['score']:.4f})") + lines.append(f" - Suggested: {', '.join(item['suggested_roles'])}") + lines.append("") + + # Score stats + score_stats = contam.get("role_score_stats", {}) + if score_stats: + lines.append("### Score Distribution per Role") + lines.append("") + lines.append("| Role | Min | Max | Avg | Count |") + lines.append("|------|-----|-----|-----|-------|") + for role, stats in sorted(score_stats.items()): + lines.append( + f"| {role} | {stats['min']:.4f} | {stats['max']:.4f} | " + f"{stats['avg']:.4f} | {stats['count']} |" + ) + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Offline benchmark harness for retrieval quality inspection.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --reference "track.mp3" + %(prog)s --reference "track1.mp3" "track2.mp3" --top-n 15 + %(prog)s --reference "track.mp3" --output results.md --format markdown + %(prog)s --reference "track.mp3" --roles kick snare hat --top-n 20 + """, + ) + + parser.add_argument( + "--reference", "-r", + nargs="+", + required=True, + help="One or more reference audio files to analyze", + ) + parser.add_argument( + "--library-dir", + default=str(_default_library_dir()), + help="Audio library directory (default: ../librerias/all_tracks)", + ) + parser.add_argument( + "--top-n", "-n", + type=int, + default=10, + help="Number of top candidates to show per role (default: 10)", + ) + parser.add_argument( + "--roles", + nargs="*", + default=None, + help="Specific roles to analyze (default: all roles)", + ) + parser.add_argument( + "--output", "-o", + type=str, + default=None, + help="Output file path for results", + ) + parser.add_argument( + "--format", "-f", + choices=["json", "markdown", "md"], + default=None, + help="Output format (json or markdown). Auto-detected from output file extension if not specified.", + ) + parser.add_argument( + "--analyze-contamination", + action="store_true", + help="Include role contamination analysis in output", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging", + ) + parser.add_argument( + "--duration-limit", + type=float, + default=None, + help="Optional duration limit for audio analysis", + ) + + args = parser.parse_args() + + # Configure logging + if args.verbose: + logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + else: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + # Validate reference files + reference_paths = [] + for ref in args.reference: + ref_path = Path(ref) + if ref_path.exists(): + reference_paths.append(str(ref_path)) + else: + logger.warning("Reference file not found: %s", ref) + + if not reference_paths: + logger.error("No valid reference files provided") + return 1 + + # Run benchmark + logger.info("Running retrieval benchmark on %d reference(s)", len(reference_paths)) + + results = run_benchmark( + reference_paths=reference_paths, + library_dir=Path(args.library_dir), + top_n=args.top_n, + roles=args.roles, + duration_limit=args.duration_limit, + ) + + # Add contamination analysis if requested + if args.analyze_contamination: + logger.info("Analyzing role contamination...") + results["contamination_analysis"] = analyze_role_contamination(results) + + # Determine output format + output_format = args.format + if output_format is None and args.output: + output_format = "markdown" if args.output.endswith(".md") else "json" + output_format = output_format or "text" + + # Format output + if output_format in ("markdown", "md"): + output_text = format_output_markdown(results) + elif output_format == "json": + output_text = format_output_json(results) + else: + # Plain text summary + output_text = format_output_markdown(results) + + # Write to file or stdout + if args.output: + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_text, encoding="utf-8") + logger.info("Results written to: %s", output_path) + else: + print(output_text) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/AbletonMCP_AI/MCP_Server/roadmap.md b/AbletonMCP_AI/MCP_Server/roadmap.md new file mode 100644 index 0000000..ad3b1b4 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/roadmap.md @@ -0,0 +1,508 @@ +# 🎛️ ROADMAP — AbletonMCP_AI hacia DJ Profesional + +> Última revisión: 2026-03-22 +> Objetivo: Sistema MCP capaz de generar, mezclar y performar sets de música electrónica a nivel profesional de club. + +--- + +## 🎯 Visión General + +``` +FASE 1 → FASE 2 → FASE 3 → FASE 4 → FASE 5 +Gain Estructura Efectos Análisis Transiciones +Staging Pro Creativos Avanzado DJ + +FASE 6 → FASE 7 → FASE 8 → FASE 9 → FASE 10 +Set Melodía Mastering Colaboración DJ Autónomo +Planning Generativa Label & Versionado Completo +``` + +--- + +## Estado Actual del Sistema + +| Módulo | Estado | Nivel Actual | Nivel Objetivo | +|---|---|---|---| +| Drum Pattern Generation | ✅ Funcional | ★★★☆☆ | ★★★★★ | +| Sample Selection | ✅ Funcional | ★★★☆☆ | ★★★★★ | +| Gain Staging | 🔧 Parcial | ★★☆☆☆ | ★★★★★ | +| Track Structure | ✅ Funcional | ★★★☆☆ | ★★★★★ | +| Reference Analysis | ✅ Funcional | ★★★☆☆ | ★★★★★ | +| Creative FX | 🔧 Parcial | ★★☆☆☆ | ★★★★☆ | +| DJ Transitions | ❌ Sin implementar | ★☆☆☆☆ | ★★★★★ | +| Set Planning | ❌ Sin implementar | ★☆☆☆☆ | ★★★★★ | +| Generative Melody | ❌ Sin implementar | ★☆☆☆☆ | ★★★★☆ | +| Mastering | ❌ Sin implementar | ★☆☆☆☆ | ★★★★★ | + +--- + +## FASE 1 — Gain Staging Profesional (Fundamento del Mix) +> _Prioridad: CRÍTICA · Estimado: 2-3 semanas_ + +La mayoría de los problemas de volumen bajo y falta de punch vienen de este bloque. Sin un gain staging correcto, todo lo demás falla. + +### 1.1 Normalización por LUFS +- [ ] **Pre-fader LUFS** — cada sample se analiza y se normaliza a -18 LUFS antes de entrar al track +- [ ] **LUFS por rol** — kick a -12 LUFS, snare a -14 LUFS, hat a -20 LUFS, bass a -16 LUFS (relaciones estándar) +- [ ] **Momentary vs integrated** — usar integrated LUFS para samples estáticos, momentary para loops +- [ ] **True peak awareness** — detectar clipeo en true peak, no solo sample peak +- [ ] **Headroom budget** — distribuir el headroom disponible entre roles con un modelo de "presupuesto de dB" + +### 1.2 Relaciones de Ganancia entre Roles +- [ ] **Drum bus total** — suma de todos los drums a -10 LUFS antes del bus +- [ ] **Bass vs kick relationship** — el kick debe ganar 2-4 dB al bass en el impacto (punch vs sustain) +- [ ] **Vocal/melody ducking** — melodías y vocales 3-6 dB por debajo del bus de batería en el drop +- [ ] **FX track attenuation** — todos los FX y atmos a -20 LUFS o menos para no saturar el mix +- [ ] **Reference comparison** — calcular diferencia de LUFS entre la generación y la referencia, ajustar + +### 1.3 Bus Routing y Suma +- [ ] **Drums bus** — kick, snare, hat, perc → Drum Bus con glue compression leve (+2 dB make-up) +- [ ] **Bass bus** — bass loop + sub → Bass Bus con limiting en -6 dBFS +- [ ] **Music bus** — synths, chords, melodía → Music Bus con suave saturación analógica +- [ ] **Vocal bus** — vocal loops, vocal shots → Vocal Bus con de-esser automático +- [ ] **FX bus** — atmos, risers, downlifters → FX Bus sin compresión, reverb send global +- [ ] **Master bus** — suma de todos los buses con limitador final a -0.3 dBFS + +### 1.4 Side-chain Automático +- [ ] **Kick → Bass** — el kick ducka el bass 8-10 dB con release de 80-150ms (el sonido más icónico del house/techno) +- [ ] **Kick → Pad** — ducking leve de 2-4 dB en pads para que el kick respire +- [ ] **Kick → Reverb send** — el kick reduce el reverb send durante su impulso (más punch) +- [ ] **Snare → Music bus** — el snare ducka suavemente el bus de música en el drop +- [ ] **Sidechain curve configuración** — curvas de ataque/release distintas por género (hard techno vs deep house) + +### 1.5 Calibración de Instrumentos Ableton +- [ ] **Simpler gain staging** — todos los clips en Simpler/Sampler con ganancia a 0 dB, nivel ajustado en pista +- [ ] **Pre/Post fader envíos** — envíos de reverb/delay siempre en post-fader +- [ ] **Return track levels** — return de reverb a -6 dB, return de delay a -12 dB como punto inicial +- [ ] **Verificar master output** — nunca superar -0.1 dBFS en pico en la master antes del limitador + +--- + +## FASE 2 — Estructura de Track y Arrangement Profesional +> _Prioridad: ALTA · Estimado: 3-4 semanas_ + +### 2.1 Arquitectura de Secciones +- [ ] **Intro largo (32+ bars)** — intro mezclable: solo kick + elementos mínimos para que el DJ anterior pueda salir +- [ ] **Warmup section (16 bars)** — añadir elementos gradualmente, hat entra a los 8 bars, bass a los 16 +- [ ] **First drop (8-16 bars)** — primer drop con todos los elementos, más corto que el segundo +- [ ] **Breakdown/Stripped (16-32 bars)** — quitar todo excepto melody/atmos, crear tensión +- [ ] **Buildup (8-16 bars)** — capas que se van sumando, sweep, riser, snare roll, tensión creciente +- [ ] **Main drop (16-32 bars)** — el momento de mayor energía, todos los elementos, impacto completo +- [ ] **Second breakdown** — variación del primero, puede tener elementos distintos +- [ ] **Second buildup** — más intenso que el first buildup +- [ ] **Re-drop / Peak (16-32 bars)** — más fuerte que el main drop, puede tener nuevo elemento +- [ ] **Outro (32+ bars)** — mirror del intro, quitar elementos progresivamente para facilitar mezcla de salida + +### 2.2 Dinámica de Energía +- [ ] **Energy curve modeling** — modelar la curva de energía como función matemática (no plana) +- [ ] **Sectional density** — calcular cuántos elementos hay activos en cada momento, mantener balance +- [ ] **Tension → Release** — cada breakdown debe crear tensión medible (menos energía → expectativa) +- [ ] **Drop impact scoring** — el drop debe tener al menos 30% más energía que la última sección tranquila +- [ ] **Post-drop variation** — segunda mitad del drop con variación para mantener el interés + +### 2.3 Fills y Transiciones Internas +- [ ] **Bar 7-8 fill** — percusión extra o variación de patrón cada 8 compases +- [ ] **16-bar macro fill** — cambio más notable cada 16 compases (nuevo elemento, variación de synth) +- [ ] **Snare roll entrance** — snare roll de 4 barras antes de cada drop +- [ ] **Crash/cymbal hit** — crash en el primer beat del drop (elemento crítico en dance music) +- [ ] **Filter automation** — high-pass filter que sube en buildup y se abre en el drop +- [ ] **Riser placement** — riser de 8-16 barras que termina exactamente en el primer beat del drop +- [ ] **Downlifter exit** — downlifter al final de los drops para marcar el end + +### 2.4 Variación Melódica +- [ ] **A/B hook structure** — dos versiones del hook principal (A en primer drop, B en re-drop) +- [ ] **Chord substitution** — reemplazar uno de los acordes de la progresión en la segunda pasada +- [ ] **Octave variation** — mover la melodía una octava arriba/abajo en el re-drop +- [ ] **Call and response** — alternar frases entre dos elementos (ej: synth → respuesta de bass) +- [ ] **Breakdown melody** — melodía simplificada o reducida durante el breakdown (solo notas principales) + +--- + +## FASE 3 — Efectos y Procesamiento Creativo +> _Prioridad: ALTA · Estimado: 3-4 semanas_ + +### 3.1 Reverb Inteligente por Sección +- [ ] **Reverb macro** — controlar el tamaño de reverb global por sección (pequeño en drop, enorme en breakdown) +- [ ] **Reverb por instrumento** — kick con room corto, snare con plate medio, pads con hall largo +- [ ] **Pre-delay automático** — pre-delay del reverb sincronizado al BPM para mantener intelligibility +- [ ] **Reverb automation curves** — el reverb crece durante el buildup, se corta en el drop (gate de reverb) +- [ ] **Reverb freeze** — congelar el reverb tail al final del breakdown para el "moment of silence" + +### 3.2 Delay Creativo +- [ ] **BPM-sync delay** — delay en tempo: 1/8, 1/4, 3/16 según el instrumento +- [ ] **Ping-pong delay** — delays stereo alternados en synths y vocales +- [ ] **Filtered delay** — delay con high-pass y low-pass para no ensuciar frecuencias +- [ ] **Delay throw** — mandar el último beat de una frase al delay para extenderla naturalmente +- [ ] **Slapback delay** — delay muy corto (30-70ms) en vocales para darles presencia + +### 3.3 Modulación y Movimiento +- [ ] **Auto-filter LFO** — filtro con LFO sincronizado al tempo en bass loops y synths +- [ ] **Phaser/Flanger automático** — aplicar phaser en el breakdown para crear movimiento sin samples +- [ ] **Chorus en strings/pads** — chorus sutil para engrosar pads y darles width +- [ ] **Tremolo rítmico** — volumen modulado en 1/8 o 1/16 para efectos de rapidez +- [ ] **Pitch modulation** — vibrato leve en melodías para humanizarlas + +### 3.4 Distorsión y Saturación Creativa +- [ ] **Analog warmth en bass** — saturación leve (1-3%) en bass para armónicos +- [ ] **Tape saturation en drums** — simular cinta en el drum bus para punch y cohesión +- [ ] **Bitcrusher en FX** — bitcrush en 8-bit durante buildups para crear tensión digital +- [ ] **Distortion send** — send bus de distorsión para añadir agresividad selectivamente +- [ ] **Clip distortion** — distorsión suave en kick para añadir transiente agresivo + +### 3.5 Stereo Image y Espacialidad +- [ ] **Mono bajo 200 Hz** — todo el contenido de sub-bass en mono (estándar de mastering) +- [ ] **Width por instrumento** — kick y bass mono, pads width 120%, melodías width 80% +- [ ] **Haas effect** — leve delay de 20-40ms en canal derecho vs izquierdo para ampliar imagen +- [ ] **M/S processing en mix** — comprimir el mid separado del side para control de espacio +- [ ] **Stereo field visualization** — calcular y reportar la correlación estéreo del mix + +### 3.6 EQ Dinámico y Automático +- [ ] **Dynamic EQ en bajos** — cortar sub-bass automáticamente cuando es demasiado denso +- [ ] **Frequency clash detection** — detectar dos instrumentos que ocupan la misma frecuencia y EQ a uno +- [ ] **HP/LP automatizado por sección** — aplicar filtros distintos según si es intro, drop, breakdown +- [ ] **Shelf EQ en master** — leve boost de high shelf (+0.5 dB a 10kHz) para aire en el mix +- [ ] **Low-end balance report** — calcular energía de sub vs mid-bass y reportar desbalance + +--- + +## FASE 4 — Análisis de Referencia Avanzado +> _Prioridad: ALTA · Estimado: 4-5 semanas_ + +### 4.1 Stem Separation de Referencia +- [ ] **Integración Demucs** — separar stems de tracks comerciales (drums, bass, melody, vocal, other) +- [ ] **Kick isolation** — extraer solo el kick de la referencia para analizar tono y punch +- [ ] **Bass isolation** — analizar frecuencia fundamental, movimiento y sidechain de la referencia +- [ ] **Dry melody extraction** — extraer melodía sin reverb de la referencia para comparar tonalidad +- [ ] **FX layer identification** — identificar qué es FX/atmos vs contenido musical en la referencia + +### 4.2 Groove y Timing Analysis +- [ ] **Swing extraction** — medir el swing (desplazamiento del tempo) de la referencia en ms +- [ ] **Groove template** — aplicar el groove de la referencia a los drum patterns generados +- [ ] **Velocity curve** — analizar la dinámica de velocidad (qué hits son más fuertes) y replicarla +- [ ] **Ghost note detection** — detectar ghost notes en la batería de referencia e insertarlas +- [ ] **Micro-timing humanization** — añadir variaciones de 2-8ms en los hits para humanizar el patrón + +### 4.3 Spectral Fingerprinting +- [ ] **Frequency balance snapshot** — captura del balance espectral (sub/low/mid/high) de la referencia +- [ ] **Spectral tilt** — medir si la referencia tiene más energía en graves o agudos y replicarlo +- [ ] **Harmonic series analysis** — identificar los armónicos dominantes del mix de referencia +- [ ] **Noise floor level** — medir el noise floor de la referencia (algunos géneros tienen ruido intencional) +- [ ] **Transient vs sustained ratio** — relación entre sonidos percusivos y sostenidos en la mezcla + +### 4.4 Arrangement Cloning +- [ ] **Section boundary detection** — detectar automáticamente dónde empiezan intro, drops, breakdowns +- [ ] **Element entrance mapping** — mapear qué elementos entran/salen en cada sección +- [ ] **Dynamic range curve** — medir la curva de dinámicas a lo largo del track y replicarla +- [ ] **Repetition pattern** — detectar cuánto se repiten las secciones (4/8/16 bars) y aplicarlo +- [ ] **Surprise element detection** — identificar momentos inesperados en la referencia (cambios de tempo, key changes) + +### 4.5 Plugin Chain Matching +- [ ] **Compression footprint** — inferir el tipo de compresión usado (attack lento/rápido, ratio alto/bajo) +- [ ] **Reverb character** — inferir tamaño y decay del reverb más usado en la referencia +- [ ] **Saturation type** — distinguir saturation analógica de distorsión digital en la referencia +- [ ] **Vocal processing chain** — inferir qué procesamiento tiene el vocal (tuning, de-ess, comp) +- [ ] **Master chain inference** — inferir si la referencia tiene limitador suave o hard, saturación de cinta, etc. + +--- + +## FASE 5 — Motor de Transiciones DJ +> _Prioridad: MUY ALTA · Estimado: 5-6 semanas_ + +### 5.1 Análisis de Compatibilidad Entre Tracks +- [ ] **BPM compatibility score** — calcular distancia de BPM y si requiere pitch shifting +- [ ] **Key compatibility (Camelot Wheel)** — verificar que los dos tracks sean armónicamente compatibles +- [ ] **Energy level matching** — el track entrante debe tener energía similar al punto de mezcla actual +- [ ] **Frequency clash in overlap** — detectar si los dos tracks generan mud en la zona de mezcla +- [ ] **Structural alignment** — alinear las frases musicales (el drop del track B sobre el drop del track A) +- [ ] **Genre fluidity score** — medir cuán compatible es el cambio de sub-género entre tracks + +### 5.2 Beatmatching Profesional +- [ ] **Grid alignment** — alinear warp grids con precisión de ±1 ms +- [ ] **Phrase-level sync** — asegurar que los cambios de frase ocurran en múltiplos de 8 compases +- [ ] **Tempo ramping** — si los BPMs difieren más de 3%, aplicar ramp gradual durante la mezcla +- [ ] **Downbeat alignment** — el downbeat del track entrante cae exactamente en el downbeat del saliente +- [ ] **Drift compensation** — compensar el drift de tempo si los tracks tienen tempo fluctuante + +### 5.3 Técnicas de Mezcla Implementadas +- [ ] **EQ transition (Bass swap)** — quitar bajos del saliente, subir bajos del entrante en 8 bars +- [ ] **Filter crossfade** — low-pass que se cierra en el saliente mientras se abre en el entrante +- [ ] **Volume crossfade** — curva S de 16-32 bars entre los dos tracks +- [ ] **Acapella moment** — desactivar instrumentos del saliente, dejar solo vocal mientras sube el entrante +- [ ] **Loop-in technique** — loopear 4 bars del saliente mientras el entrante se estabiliza +- [ ] **Drop-to-drop transition** — ambos tracks en el drop simultáneamente por 8 bars, luego salida +- [ ] **Breakdown blend** — salida en breakdown del saliente, entrada en breakdown del entrante +- [ ] **Spinback exit** — efecto de parada brusca seguido de entrada del nuevo track +- [ ] **Echo exit** — el saliente sale con delay doblado y pitch shifting lento + +### 5.4 Automatización de Efectos en Transición +- [ ] **Reverb tail extension** — alargar el reverb del saliente para suavizar la salida +- [ ] **Filter automation** — HP filter sube en el saliente, se abre en el entrante +- [ ] **Flanger/phaser sweep** — sweep de efecto de modulación durante los 4 bars de transición +- [ ] **White noise sweep** — ruido blanco filtrado que sube en el buildup y baja en el drop +- [ ] **Reverb gate clap** — clap gateado que actúa como puente entre los dos tracks + +### 5.5 Mashup y Mezcla Creativa +- [ ] **Vocal steal** — tomar el vocal loop de Track A y colocarlo sobre el instrumental de Track B +- [ ] **Percussion layer** — sumar el top loop de Track A a la batería de Track B por 8 bars +- [ ] **Bass substitution** — reemplazar el bass del Track A con el del Track B durante la transición +- [ ] **Counter-melody blend** — sumar la melodía de Track A como contrapunto de Track B +- [ ] **Energy booster** — si el Track B tiene menos energía, temporalmente sumar samples de impacto + +--- + +## FASE 6 — Set Planning e Inteligencia de Flujo +> _Prioridad: ALTA · Estimado: 4-5 semanas_ + +### 6.1 Arquitectura del Set +- [ ] **Set duration planning** — dado duración total (30/60/90/120 min), planear cantidad de tracks y transiciones +- [ ] **Energy arc model** — warm-up (20%) → build (30%) → peak (30%) → comedown (20%) +- [ ] **BPM progression curve** — ramp de BPM configurable, ej: 122 → 130 → 128 para cierre +- [ ] **Key journey** — progresión harmónica a través del set usando Camelot Wheel +- [ ] **Genre morphing** — transición suave de sub-géneros: deep house → tech house → techno → industrial + +### 6.2 Generación de Tracklist +- [ ] **Opener selection** — tracks de apertura con intro largo, minimalistas, poco frecuente en sets +- [ ] **Peak hour tracks** — tracks más intensos reservados para la hora de mayor energía +- [ ] **Closer track** — track de cierre con outro largo, emotivo o minimalista +- [ ] **Surprise track placement** — posicionar tracks "inesperados" (diferente BPM, key, género) en puntos clave +- [ ] **Diversity enforcement** — no repetir mismo artista, mismo pack de samples o misma key en 3 tracks seguidos + +### 6.3 Gestión de Canciones Generadas +- [ ] **Song catalog** — base de datos de todos los tracks generados con metadata completa +- [ ] **Playability score** — puntuar cada track por cuán mezclable es (intro/outro length, LUFS, key) +- [ ] **Set history** — registrar qué tracks se tocaron en qué sets para no repetir +- [ ] **Usage stats** — cuántas veces se tocó cada track, temperatura del hit +- [ ] **Tagging system** — tags de estado: draft, mix-ready, vetted, retired + +### 6.4 Flujo de Noche Dinámica +- [ ] **Crowd response adaptation** — ajustar la energía planeada basado en feedback del operador +- [ ] **Emergency track pool** — banco de tracks de relleno por si hay problemas técnicos +- [ ] **Mood pivot** — si la energía del set no está funcionando, sugerir pivot de mood +- [ ] **Timing buffer** — mantener siempre 2-3 tracks listos de antemano para mezcla inmediata +- [ ] **Live override** — el operador puede insertar un track manual y el sistema replanning el resto + +### 6.5 Generación de Variantes por Función +- [ ] **Dub mix** — versión con menos elementos para usar durante mezclas (sin melodía principal) +- [ ] **DJ Tool** — track sin intro ni melodía, solo ritmo y textura para mezclar con otro track +- [ ] **Club edit** — versión más corta del track (5-6 min vs 7+ min) para sets con tiempo limitado +- [ ] **Radio edit** — versión de 3.5 min con fade-in y fade-out, sin intro largo +- [ ] **Extended mix** — versión con intro/outro de 64 bars cada uno, para mezcla profesional + +--- + +## FASE 7 — Generación Musical Procedural +> _Prioridad: MEDIA-ALTA · Estimado: 6-8 semanas_ + +### 7.1 Síntesis de Melodías +- [ ] **Scale-aware melody** — generar melodías que respeten la escala detectada (mayor, menor, dórico, frigio) +- [ ] **Interval engine** — generar intervalos musicalmente interesantes (3ras, 5tas, 6tas), no solo secuencias lineales +- [ ] **Phrase structure** — melodías de 2/4 bars con pregunta (bars 1-2) y respuesta (bars 3-4) +- [ ] **Tension/resolution** — usar la 7ª como nota de tensión, resolver a la 1ª o 5ª +- [ ] **Motif engine** — crear un motivo de 2-3 notas y repetirlo con variaciones a lo largo del track +- [ ] **Counter-melody** — generar una contra-melodía que complementa la principal +- [ ] **Ascending/descending lines** — detectar si el mood pide melodía ascendente (buildup) o descendente (breakdown) + +### 7.2 Progresiones de Acordes +- [ ] **Genre-specific chord library** — banco de progresiones por género (house, techno, trance, dnb) +- [ ] **Function-aware chords** — I–IV–V–I (tonal), ii–V–I (jazz), i–VII–VI–VII (modal techno) +- [ ] **Chord voicing** — voicings distintos por registro (close voicing en graves, open en agudos) +- [ ] **Inversions** — usar inversiones de acordes para crear smooth voice leading entre acordes +- [ ] **Pedal point** — nota pedal sostenida en el bass mientras los acordes cambian arriba +- [ ] **Suspended chords** — usar sus2 y sus4 para crear tensión sin disonancia abierta +- [ ] **Modal interchange** — préstamo de acordes de modos paralelos para color emocional + +### 7.3 Líneas de Bajo Generadas +- [ ] **Root note bass** — línea de bajo sobre las raíces de los acordes, rítmica y sincopada +- [ ] **Walking bass** — línea de bajo que se mueve por grados de escala hacia cada acorde +- [ ] **Acid bass pattern** — patrón tipo TB-303 con slides, accents y rests aleatorios dentro de escala +- [ ] **Sub + Mid split** — separar el sub (frecuencias <80Hz) del mid-bass (80-250Hz) para procesamiento distinto +- [ ] **Octave doubling** — doblar la línea de bajo una octava arriba para cuerpo y definición + +### 7.4 Síntesis de Batería +- [ ] **Kick synthesis** — generar kicks sintéticos con seno + click + pitch envelope (estilo TR-909) +- [ ] **Snare synthesis** — ruido + tonal con parámetros de color, "crack" y "body" +- [ ] **Hat synthesis** — ruido filtrado con envelope de decay muy corto, variaciones de apertura +- [ ] **Clap layering** — múltiples ruidos cortos desfasados levemente para clap orgánico +- [ ] **Transient design** — ajustar por separado el ataque y el "cuerpo" de cada drum hit + +### 7.5 Texturas y Atmósferas Generativas +- [ ] **Drone generation** — generar un drone en la tónica del track para dar sustento armónico +- [ ] **Granular texture** — usar síntesis granular sobre un sample para crear texturas únicas +- [ ] **Noise color selection** — blanco, rosado o marrón según el mood y la sección del track +- [ ] **Stochastic modulation** — parámetros de synth que cambian aleatoriamente dentro de un rango +- [ ] **Evolving pad** — pad que cambia lentamente de carácter a lo largo del track usando automación + +--- + +## FASE 8 — Mastering Automático de Nivel Label +> _Prioridad: MEDIA · Estimado: 4-5 semanas_ + +### 8.1 Target Loudness por Destino +- [ ] **Streaming master** — -14 LUFS integrated, -1 dBFS true peak (estándar Spotify/Apple) +- [ ] **Club master** — -6 LUFS integrated, -0.3 dBFS true peak (para sistemas PA) +- [ ] **Broadcast master** — -23 LUFS integrated (EBU R128/ATSC A/85) +- [ ] **Vinyl master** — limitado en sub-bass, fase mono, -12 LUFS (limitaciones físicas del vinilo) +- [ ] **DJ DJ USB** — -9 LUFS, formato WAV 24bit para Pioneer CDJ/XDJ + +### 8.2 Cadena de Mastering +- [ ] **EQ de mastering** — corrección tonal amplia: leve boost de aire, corrección de resonancias +- [ ] **Mid-side EQ** — expandir el side, comprimir el mid para imagen más profesional +- [ ] **Multi-band compression** — 3-4 bandas de compresión suave para control de dinámica por rango +- [ ] **Stereo enhancer** — ampliar levemente el mid-high para más espacio sin afectar el sub +- [ ] **Tape emulation** — saturación de cinta leve en el master para calidez analógica +- [ ] **Limiting** — limiting con lookahead de 2-8ms, attack rápido, release configurado al BPM +- [ ] **True peak limiting** — segundo limiter post-master para garantizar true peak dentro del target + +### 8.3 Análisis y QC del Master +- [ ] **Loudness report** — integrated LUFS, momentary LUFS max, LRA (loudness range), true peak +- [ ] **Spectral balance report** — gráfico comparando la distribución espectral vs referencia comercial +- [ ] **Phase correlation** — verificar que la correlación estéreo sea positiva (>0.5) para compatibilidad mono +- [ ] **Clipping check** — escanear el master en busca de clips o inter-sample peaks +- [ ] **A/B comparison protocol** — comparar el master vs referencia con ganancia compensada (mismo LUFS) + +### 8.4 Dithering y Formato Final +- [ ] **Dithering** — aplicar dithering TPDF al convertir de 32-bit float a 16/24-bit PCM +- [ ] **Format conversion** — WAV 24bit/48kHz (producción), WAV 16bit/44.1kHz (CD), FLAC (archivo) +- [ ] **MP3 encoding** — export MP3 320kbps para uso en software DJ (CBR, joint stereo) +- [ ] **Metadata embedding** — BPM, key, genre, ISRC, album art en los metadatos del archivo final +- [ ] **File naming convention** — `[artist]_[title]_[bpm]_[key]_[version].[ext]` automático + +### 8.5 Revisión por Ia Antes del Master +- [ ] **Pre-master checklist** — verificar que el mix cumple con los criterios antes de masterizar +- [ ] **Headroom verification** — el mix no supera -6 dBFS antes de entrar al master chain +- [ ] **Low-end mono check** — confirmar que el sub es mono y el bass no supera el kick en volumen +- [ ] **Reverb tail check** — que no haya colas de reverb que superen el tempo al final de las frases +- [ ] **Dropout detection** — detectar silencios inesperados o glitches en el audio antes de masterizar + +--- + +## FASE 9 — Colaboración, Versionado y Producción en Equipo +> _Prioridad: MEDIA · Estimado: 4-6 semanas_ + +### 9.1 Versionado de Sesiones +- [ ] **Version history** — cada sesión generada se guarda con timestamp y metadata completa +- [ ] **Named versions** — versiones con nombre: v1_rough_mix, v2_with_drops, v3_final +- [ ] **Diff between versions** — mostrar qué cambió entre dos versiones (BPM, key, samples usados) +- [ ] **Rollback** — volver a cualquier versión anterior con un comando +- [ ] **Branch system** — crear variantes paralelas de un track sin sobrescribir el original + +### 9.2 Documentación Musical Automática +- [ ] **Production notes** — exportar documento con todos los samples usados, BPM, key, settings +- [ ] **Sample clearance report** — marcar qué samples son de librerías royalty-free y cuáles no +- [ ] **Arrangement timeline** — exportar un diagrama de la estructura del track (intro, verse, drop, etc.) +- [ ] **Plugin settings export** — guardar todos los parámetros de los devices de Ableton usados +- [ ] **Collaboration template** — exportar el proyecto en formato que otro productor pueda retomar + +### 9.3 Gestión de Sample Library +- [ ] **Sample usage tracking** — registrar qué samples se usan en qué tracks +- [ ] **Overused sample detection** — alertar si el mismo sample aparece en más de 3 tracks del mismo período +- [ ] **Library gap analysis** — detectar qué categorías de samples son escasas en la librería +- [ ] **Sample rating system** — votar samples (1-5 estrellas), excluir los de baja calidad de la selección +- [ ] **Pack organization** — organizar samples por "pack" (colección de origen) para coherencia tonal + +### 9.4 Exportación y Distribución +- [ ] **Stem export automático** — exportar cada bus como archivo separado (drums, bass, music, vocal, fx) +- [ ] **Stem naming convention** — nombres con rol y número de proyecto incluido +- [ ] **ZIP release package** — empaquetar master, stems, artwork y notes en un ZIP listo para distribuir +- [ ] **Streaming metadata** — metadata en formato compatible con DistroKid/TuneCore/CD Baby +- [ ] **Cover art generation** — generar artwork minimalista basado en género/mood (integración DALL-E o similar) + +### 9.5 Retroalimentación y Aprendizaje +- [ ] **A/B testing de tracks generados** — comparar dos versiones y registrar cuál se prefiere +- [ ] **Production log** — registro de decisiones creativas tomadas por el sistema con justificación +- [ ] **Error pattern learning** — registrar qué parámetros produjeron resultados malos y evitarlos +- [ ] **Style evolution tracking** — documentar cómo evoluciona el "estilo" del sistema a lo largo del tiempo +- [ ] **External feedback integration** — formulario para que el DJ/productor califica el resultado + +--- + +## FASE 10 — DJ Autónomo Completo +> _Prioridad: MEDIA-BAJA · Estimado: 8-12 semanas_ + +Esta es la fase final: el sistema es capaz de planear, generar, mezclar y performar un set completo de forma completamente autónoma, con mínima intervención humana. + +### 10.1 Generación de Set Completo End-to-End +- [ ] **One-command set** — `generate_set(duration=60, genre='techno', mood='dark')` produce un set completo +- [ ] **Coherent sound palette** — todos los tracks del set comparten elementos sonoros para coherencia +- [ ] **Progression narrative** — el set cuenta una "historia" musical de apertura hasta el tema emocional +- [ ] **Auto-transition rendering** — todas las transiciones pre-renderizadas y listas para playback +- [ ] **Continuous mix export** — exportar el set completo como un archivo de audio sin cortes + +### 10.2 Performance en Tiempo Real +- [ ] **Live generation** — generar el próximo track mientras el actual está siendo tocado +- [ ] **Real-time transition adjustment** — ajustar parámetros de transición basado en lo que está sonando +- [ ] **Hot cue system** — colocar hot cues automáticamente en los puntos de mezcla óptimos +- [ ] **Loop juggling AI** — el sistema decide cuándo loopear, cuándo romper el loop para máximo impacto +- [ ] **FX performance** — disparar efectos en momentos clave (reverb throw, filter sweep) automáticamente + +### 10.3 Respuesta a Contexto +- [ ] **Time-of-night awareness** — detectar por reloj si es apertura, peak o cierre y adaptar la energía +- [ ] **Venue size adaptation** — configurar para cuarto pequeño (íntimo, técnico) vs festival (más épico) +- [ ] **Genre request handling** — el operador pide "más oscuro", "más rápido", "más groovy" en lenguaje natural +- [ ] **Emergency handling** — si un track no carga o falla, el sistema selecciona un reemplazo en <1 segundo +- [ ] **BPM tempo lock** — nunca salirse de un rango de BPM configurado aunque la selección lo sugiera + +### 10.4 Inteligencia Emocional Musical +- [ ] **Mood lexicon** — vocabulario de moods con sus características técnicas (dark = menor, lento, menos brillo) +- [ ] **Energy trajectory** — predecir cómo va a evolucionar la energía de los próximos 20 minutos +- [ ] **Listener journey modeling** — modelar la experiencia del oyente como una narrativa con arcos +- [ ] **Surprise injection** — agregar momentos inesperados cada 20 minutos para mantener atención +- [ ] **Emotional contrast** — garantizar contrastes de intensidad para que el peak moment sea más impactante + +### 10.5 Aprendizaje Continuo +- [ ] **Session reinforcement learning** — cada set mejora el planeamiento del siguiente +- [ ] **Style drift detection** — detectar si el sistema tiende a repetir los mismos patrones y corrección automática +- [ ] **Trend awareness** — analizar tracks nuevos periódicamente para mantenerse al día con el sonido actual +- [ ] **Personal style refinement** — refinar el "DNA sonoro" del DJ basado en feedback acumulado +- [ ] **Cross-genre inspiration** — ocasionalmente tomar elementos de géneros no habituales para innovar + +--- + +## 🚀 Quick Wins (valor inmediato, 1-3 días cada uno) + +| # | Feature | Fase | Impacto | Esfuerzo | +|---|---|---|---|---| +| 1 | **Side-chain kick → bass** | 1.4 | 🔥🔥🔥 | Bajo | +| 2 | **Intro/outro de 32 bars** | 2.1 | 🔥🔥🔥 | Bajo | +| 3 | **LUFS normalization por track** | 1.1 | 🔥🔥🔥 | Bajo | +| 4 | **HP filter automático en intro** | 3.6 | 🔥🔥 | Bajo | +| 5 | **Camelot Wheel key compatibility** | 5.1 | 🔥🔥 | Bajo | +| 6 | **Crash on first beat of drop** | 2.3 | 🔥🔥 | Bajo | +| 7 | **BPM y Key en metadata del archivo** | 8.4 | 🔥 | Bajo | +| 8 | **Snare roll en buildup (4 bars)** | 2.3 | 🔥🔥 | Bajo | +| 9 | **Reverb tail al salir del breakdown** | 3.1 | 🔥🔥 | Medio | +| 10 | **Stereo mono abajo de 200Hz** | 3.5 | 🔥🔥 | Bajo | + +--- + +## 💡 Criterio de "DJ Profesional" — Checklist de Aceptación + +Un sistema MCP alcanza nivel DJ profesional cuando puede superar todos estos criterios: + +### Técnicos +- [ ] El LUFS integrado de cada track está entre -9 y -8 dBFS (nivel club) +- [ ] Nunca hay clipping ni distorsión no intencional en ningún track +- [ ] El sub-bass es mono en todos los tracks generados +- [ ] El side-chain kick→bass está funcionando y se puede escuchar claramente +- [ ] Todas las transiciones entre tracks son musicalmente coherentes + +### Estructurales +- [ ] Cada track tiene al menos 32 bars de intro mezclable +- [ ] Cada track tiene al menos 32 bars de outro mezclable +- [ ] El drop tiene más energía que cualquier sección previa +- [ ] El breakdown es notablemente más tranquilo que el drop +- [ ] El buildup crea anticipación audible antes del drop + +### DJ Performance +- [ ] El sistema puede mezclar dos tracks en menos de 16 bars de superposición +- [ ] El key matching garantiza que los dos tracks suenan harmónicos juntos +- [ ] Un set de 60 minutos mantiene un arco de energía coherente +- [ ] No se repite el mismo sample prominente dentro del mismo set +- [ ] El set se puede tocar en una pista sin vergüenza + +### Emocional +- [ ] Hay un "momento" memorable en cada track (un riff, un drop, un silencio) +- [ ] El set tiene un "peak moment" claramente identificable +- [ ] La música crea una respuesta física (ganas de mover los pies) +- [ ] Hay coherencia de mood aunque varíe la energía +- [ ] El set cuenta una historia que tiene inicio, clímax y cierre diff --git a/AbletonMCP_AI/MCP_Server/role_matcher.py b/AbletonMCP_AI/MCP_Server/role_matcher.py new file mode 100644 index 0000000..a2a79a8 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/role_matcher.py @@ -0,0 +1,469 @@ +""" +role_matcher.py - Phase 4: Role validation and sample matching utilities + +This module provides enhanced role matching for sample selection with: +- Role validation based on audio characteristics +- Aggressive sample detection and filtering +- Logging of matching decisions +- Integration with reference_listener and sample_selector +""" + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("RoleMatcher") + + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +# Valid roles for sample matching with their expected characteristics +VALID_ROLES = { + # One-shot drums + "kick": {"max_duration": 2.0, "min_onset": 0.3, "is_loop": False, "bus": "drums"}, + "snare": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, + "hat": {"max_duration": 1.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, + "clap": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, + "ride": {"max_duration": 3.0, "min_onset": 0.15, "is_loop": False, "bus": "drums"}, + "perc": {"max_duration": 2.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, + # Loops + "bass_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "bass"}, + "perc_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, + "top_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, + "synth_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "music"}, + "vocal_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "vocal"}, + # FX + "crash_fx": {"max_duration": 4.0, "is_loop": False, "bus": "fx"}, + "fill_fx": {"max_duration": 8.0, "is_loop": False, "bus": "fx"}, + "snare_roll": {"max_duration": 8.0, "is_loop": False, "bus": "drums"}, + "atmos_fx": {"min_duration": 4.0, "is_loop": True, "bus": "fx"}, + "vocal_shot": {"max_duration": 3.0, "is_loop": False, "bus": "vocal"}, + # Resample layers + "resample_reverse": {"is_loop": False, "bus": "fx"}, + "resample_riser": {"is_loop": False, "bus": "fx"}, + "resample_downlifter": {"is_loop": False, "bus": "fx"}, + "resample_stutter": {"is_loop": False, "bus": "vocal"}, +} + +# Keywords that indicate aggressive/hard samples that may be misclassified +AGGRESSIVE_KEYWORDS = { + # Very aggressive kick patterns + "hard", "distorted", "industrial", "slam", "punch", "brutal", + # Potentially misclassified + "subdrop", "impact", "explosion", "destroy", +} + +# Keywords that are acceptable for aggressive genres +GENRE_APPROPRIATE_AGGRESSIVE = { + "industrial-techno", "hard-techno", "raw-techno", "psytrance", "dark-techno" +} + +# Role aliases for flexible matching +ROLE_ALIASES = { + "kick": ["kick", "bd", "bassdrum", "bass_drum"], + "snare": ["snare", "sd", "snr"], + "clap": ["clap", "cp", "handclap"], + "hat": ["hat", "hihat", "hi_hat", "hhat", "closed_hat", "hat_closed"], + "hat_open": ["open_hat", "hat_open", "ohat", "openhihat"], + "ride": ["ride", "rd", "cymbal"], + "perc": ["perc", "percussion", "percs"], + "bass_loop": ["bass_loop", "bassloop", "bass loop", "sub_bass"], + "perc_loop": ["perc_loop", "percloop", "percussion loop", "perc loop"], + "top_loop": ["top_loop", "toploop", "top loop", "full_drum"], + "synth_loop": ["synth_loop", "synthloop", "synth loop", "chord_loop", "stab"], + "vocal_loop": ["vocal_loop", "vocalloop", "vocal loop", "vox_loop", "vox"], + "crash_fx": ["crash", "crash_fx", "crashfx", "impact_fx"], + "fill_fx": ["fill", "fill_fx", "fillfx", "tom_fill", "transition"], + "snare_roll": ["snare_roll", "snareroll", "snare roll", "snr_roll"], + "atmos_fx": ["atmos", "atmos_fx", "atmosfx", "drone", "pad_fx"], + "vocal_shot": ["vocal_shot", "vocalshot", "vocal shot", "vocal_one_shot"], +} + +# Minimum score thresholds for role matching +ROLE_SCORE_THRESHOLDS = { + "kick": 0.35, + "snare": 0.32, + "hat": 0.30, + "clap": 0.32, + "bass_loop": 0.38, + "perc_loop": 0.35, + "top_loop": 0.35, + "synth_loop": 0.36, + "vocal_loop": 0.38, + "crash_fx": 0.30, + "fill_fx": 0.32, + "snare_roll": 0.30, + "atmos_fx": 0.32, + "vocal_shot": 0.34, +} + + +# ============================================================================ +# VALIDATION FUNCTIONS +# ============================================================================ + +def validate_role_for_sample( + role: str, + sample_data: Dict[str, Any], + genre: Optional[str] = None, +) -> Dict[str, Any]: + """ + Validates if a sample is appropriate for a given role. + + Args: + role: The role to validate for (e.g., 'kick', 'bass_loop') + sample_data: Sample metadata with keys like 'duration', 'onset_mean', 'file_name', 'rms_mean' + genre: Optional genre for context-aware aggressive sample handling + + Returns: + Dict with keys: + - 'valid' (bool): Whether the sample passes validation + - 'score' (float): Raw validation score (0.0-1.0) + - 'warnings' (list): List of warning messages + - 'adjusted_score' (float): Score after penalties + """ + if role not in VALID_ROLES: + return {"valid": True, "score": 0.5, "warnings": [f"Unknown role: {role}"], "adjusted_score": 0.5} + + role_config = VALID_ROLES[role] + warnings: List[str] = [] + score = 1.0 + + duration = float(sample_data.get("duration", 0.0) or 0.0) + onset = float(sample_data.get("onset_mean", 0.0) or 0.0) + file_name = str(sample_data.get("file_name", "") or "").lower() + rms = float(sample_data.get("rms_mean", 0.0) or 0.0) + + # Duration validation + if role_config.get("is_loop"): + min_dur = role_config.get("min_duration", 2.0) + max_dur = role_config.get("max_duration", 16.0) + if duration < min_dur: + warnings.append(f"Duration {duration:.1f}s too short for loop role (min {min_dur}s)") + score *= 0.7 + elif max_dur and duration > max_dur: + warnings.append(f"Duration {duration:.1f}s too long for role (max {max_dur}s)") + score *= 0.85 + else: + max_dur = role_config.get("max_duration", 3.0) + if duration > max_dur: + warnings.append(f"Duration {duration:.1f}s too long for one-shot role (max {max_dur}s)") + score *= 0.75 + if "loop" in file_name and role in ["kick", "snare", "hat", "clap"]: + warnings.append("One-shot role has 'loop' in filename") + score *= 0.65 + + # Onset validation for percussive elements + min_onset = role_config.get("min_onset", 0.0) + if min_onset > 0 and onset < min_onset: + warnings.append(f"Onset {onset:.2f} below minimum {min_onset:.2f}") + score *= 0.85 + + # Check for aggressive samples that might be misclassified + aggressive_penalty = 1.0 + is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE + + for keyword in AGGRESSIVE_KEYWORDS: + if keyword in file_name: + if not is_aggressive_genre: + aggressive_penalty *= 0.88 + warnings.append(f"Aggressive keyword '{keyword}' found for non-aggressive genre") + + score *= aggressive_penalty + + # RMS validation for certain roles + if role in ["kick", "snare", "clap"] and rms > 0.4: + warnings.append(f"High RMS {rms:.3f} for one-shot role") + score *= 0.9 + + adjusted_score = max(0.1, min(1.0, score)) + + return { + "valid": score >= 0.4, + "score": score, + "warnings": warnings, + "adjusted_score": adjusted_score, + } + + +def resolve_role_from_alias(alias: str) -> Optional[str]: + """ + Resolves a role name from various aliases. + + Args: + alias: A potential role alias (e.g., 'bd', 'hihat', 'bass loop') + + Returns: + The canonical role name or None if not found + """ + alias_lower = alias.lower().strip().replace("-", "_").replace(" ", "_") + + # Direct match + if alias_lower in VALID_ROLES: + return alias_lower + + # Check aliases + for role, aliases in ROLE_ALIASES.items(): + normalized_aliases = [a.lower().replace("-", "_").replace(" ", "_") for a in aliases] + if alias_lower in normalized_aliases: + return role + + return None + + +def get_bus_for_role(role: str) -> str: + """ + Gets the appropriate bus for a role. + + Args: + role: The role name + + Returns: + Bus name ('drums', 'bass', 'music', 'vocal', or 'fx') + """ + if role in VALID_ROLES: + return VALID_ROLES[role].get("bus", "music") + return "music" + + +# ============================================================================ +# LOGGING FUNCTIONS +# ============================================================================ + +def log_matching_decision( + role: str, + selected_sample: Optional[Dict[str, Any]], + candidates_count: int, + final_score: float, + validation_result: Optional[Dict[str, Any]] = None, +) -> None: + """ + Logs detailed matching decisions for debugging and analysis. + + Args: + role: The role being matched + selected_sample: The selected sample dict or None + candidates_count: Number of candidates considered + final_score: The final matching score + validation_result: Optional validation result dict + """ + if not selected_sample: + logger.info( + f"[MATCH] Role '{role}': No sample selected (0/{candidates_count} candidates)" + ) + return + + sample_name = selected_sample.get("file_name", "unknown") + sample_tempo = selected_sample.get("tempo", 0.0) + sample_key = selected_sample.get("key", "N/A") + sample_dur = selected_sample.get("duration", 0.0) + + log_parts = [ + f"[MATCH] Role '{role}':", + f"Sample: {sample_name}", + f"Score: {final_score:.3f}", + f"Tempo: {sample_tempo:.1f}", + f"Key: {sample_key}", + f"Duration: {sample_dur:.1f}s", + f"Candidates: {candidates_count}", + ] + + if validation_result: + warnings = validation_result.get("warnings", []) + if warnings: + log_parts.append(f"Warnings: {', '.join(warnings)}") + log_parts.append(f"Validated: {validation_result.get('valid', True)}") + + logger.info(" | ".join(log_parts)) + + +# ============================================================================ +# ENHANCEMENT FUNCTIONS +# ============================================================================ + +def enhance_sample_matching( + matches: Dict[str, List[Dict[str, Any]]], + reference: Dict[str, Any], + genre: Optional[str] = None, +) -> Dict[str, List[Dict[str, Any]]]: + """ + Enhances sample matching results with validation and filtering. + + This function takes raw matches from reference_listener and applies: + 1. Role validation based on audio characteristics + 2. Aggressive sample filtering + 3. Score adjustment based on validation results + + Args: + matches: Raw matches from reference_listener (role -> list of sample dicts) + reference: Reference track analysis data + genre: Target genre for context-aware filtering + + Returns: + Enhanced matches with validation scores and filtering applied + """ + enhanced: Dict[str, List[Dict[str, Any]]] = {} + + for role, candidates in matches.items(): + if not candidates: + enhanced[role] = [] + continue + + threshold = ROLE_SCORE_THRESHOLDS.get(role, 0.30) + enhanced_candidates: List[Dict[str, Any]] = [] + + for candidate in candidates: + # Create a copy to avoid modifying the original + enhanced_candidate = dict(candidate) + + # Validate the sample for this role + validation = validate_role_for_sample(role, candidate, genre) + enhanced_candidate["validation"] = validation + + # Apply validation penalty to the score + original_score = float(candidate.get("score", 0.0)) + adjusted_score = original_score * validation["adjusted_score"] + enhanced_candidate["adjusted_score"] = round(adjusted_score, 6) + + # Filter out samples below threshold + if adjusted_score >= threshold: + enhanced_candidates.append(enhanced_candidate) + else: + logger.debug( + f"[FILTER] Role '{role}': Filtered out '{candidate.get('file_name', 'unknown')}' " + f"(score {adjusted_score:.3f} < threshold {threshold})" + ) + + # Re-sort by adjusted score + enhanced_candidates.sort(key=lambda x: float(x.get("adjusted_score", 0.0)), reverse=True) + enhanced[role] = enhanced_candidates + + # Log summary + filtered_count = len(candidates) - len(enhanced_candidates) + if filtered_count > 0: + logger.info( + f"[ENHANCE] Role '{role}': {len(enhanced_candidates)}/{len(candidates)} candidates passed validation " + f"({filtered_count} filtered out)" + ) + + return enhanced + + +def filter_aggressive_samples( + candidates: List[Dict[str, Any]], + genre: Optional[str] = None, + strict: bool = False, +) -> List[Dict[str, Any]]: + """ + Filters out samples with aggressive keywords unless appropriate for the genre. + + Args: + candidates: List of sample candidate dicts + genre: Target genre + strict: If True, apply stricter filtering + + Returns: + Filtered list of candidates + """ + is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE + + if is_aggressive_genre: + # For aggressive genres, don't filter aggressive samples + return candidates + + filtered = [] + for candidate in candidates: + file_name = str(candidate.get("file_name", "") or "").lower() + aggressive_count = sum(1 for kw in AGGRESSIVE_KEYWORDS if kw in file_name) + + if strict and aggressive_count > 0: + continue + + # Apply penalty instead of filtering completely + if aggressive_count > 0: + penalty = 0.85 ** aggressive_count + candidate_copy = dict(candidate) + original_score = float(candidate.get("score", 0.0)) + candidate_copy["score"] = original_score * penalty + filtered.append(candidate_copy) + else: + filtered.append(candidate) + + return filtered + + +# ============================================================================ +# INTEGRATION HELPERS +# ============================================================================ + +def create_enhanced_match_report( + role: str, + selected_sample: Optional[Dict[str, Any]], + all_candidates: List[Dict[str, Any]], + validation_result: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Creates a detailed report for a matching decision. + + Args: + role: The role being matched + selected_sample: The selected sample + all_candidates: All candidates that were considered + validation_result: Validation result for the selected sample + + Returns: + A dict with detailed matching report + """ + report = { + "role": role, + "selected": selected_sample is not None, + "candidates_count": len(all_candidates), + "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), + } + + if selected_sample: + report["selected_sample"] = { + "name": selected_sample.get("file_name"), + "path": selected_sample.get("path"), + "score": selected_sample.get("score"), + "adjusted_score": selected_sample.get("adjusted_score"), + "tempo": selected_sample.get("tempo"), + "key": selected_sample.get("key"), + "duration": selected_sample.get("duration"), + } + + if validation_result: + report["validation"] = { + "valid": validation_result.get("valid"), + "score": validation_result.get("score"), + "warnings": validation_result.get("warnings", []), + } + + return report + + +def get_role_info(role: str) -> Dict[str, Any]: + """ + Gets comprehensive information about a role. + + Args: + role: The role name + + Returns: + Dict with role information including valid samples count, thresholds, etc. + """ + if role not in VALID_ROLES: + return {"error": f"Unknown role: {role}"} + + config = VALID_ROLES[role] + aliases = ROLE_ALIASES.get(role, []) + + return { + "role": role, + "config": config, + "aliases": aliases, + "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), + "bus": config.get("bus", "music"), + "is_loop": config.get("is_loop", False), + } \ No newline at end of file diff --git a/AbletonMCP_AI/MCP_Server/sample_index.py b/AbletonMCP_AI/MCP_Server/sample_index.py new file mode 100644 index 0000000..186b338 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/sample_index.py @@ -0,0 +1,308 @@ +""" +sample_index.py - Índice y búsqueda de samples para AbletonMCP-AI + +Gestiona la librería de samples locales con metadatos extraídos de los nombres. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +import re + +logger = logging.getLogger("SampleIndex") + + +class SampleIndex: + """Índice de samples con búsqueda y metadatos""" + + # Categorías por palabras clave + CATEGORIES = { + 'kick': ['kick', 'bd', 'bass drum', 'kick drum'], + 'snare': ['snare', 'sd', 'snr'], + 'clap': ['clap', 'clp'], + 'hat': ['hat', 'hh', 'hihat', 'hi-hat', 'closed hat', 'open hat'], + 'perc': ['perc', 'percussion', 'conga', 'bongo', 'shaker', 'tamb', 'timb'], + 'bass': ['bass', 'bassline', 'sub', '808', ' Reese'], + 'synth': ['synth', 'lead', 'pad', 'arp', 'pluck', 'stab', 'chord'], + 'vocal': ['vocal', 'vox', 'voice', 'speech', 'talk'], + 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit'], + 'loop': ['loop', 'full', 'groove'], + } + + def __init__(self, base_dir: str): + """ + Inicializa el índice de samples + + Args: + base_dir: Directorio base donde buscar samples + """ + self.base_dir = Path(base_dir) + self.samples: List[Dict[str, Any]] = [] + self.index_file = self.base_dir / ".sample_index.json" + + # Cargar o construir índice + if self.index_file.exists(): + self._load_index() + else: + self._build_index() + self._save_index() + + def _build_index(self): + """Construye el índice escaneando el directorio""" + logger.info(f"Construyendo índice de samples en: {self.base_dir}") + + extensions = {'.wav', '.aif', '.aiff', '.mp3', '.ogg'} + + for file_path in self.base_dir.rglob('*'): + if file_path.suffix.lower() in extensions: + sample_info = self._analyze_sample(file_path) + self.samples.append(sample_info) + + logger.info(f"Índice construido: {len(self.samples)} samples encontrados") + + def _analyze_sample(self, file_path: Path) -> Dict[str, Any]: + """Analiza un sample y extrae metadatos del nombre""" + name = file_path.stem + name_lower = name.lower() + + # Determinar categoría + category = self._detect_category(name_lower) + + # Extraer key del nombre + key = self._extract_key(name) + + # Extraer BPM del nombre + bpm = self._extract_bpm(name) + + return { + 'name': name, + 'path': str(file_path), + 'category': category, + 'key': key, + 'bpm': bpm, + 'size': file_path.stat().st_size if file_path.exists() else 0, + } + + def _detect_category(self, name: str) -> str: + """Detecta la categoría basada en palabras clave""" + for category, keywords in self.CATEGORIES.items(): + for keyword in keywords: + if keyword in name: + return category + return 'unknown' + + def _extract_key(self, name: str) -> Optional[str]: + """Extrae la tonalidad del nombre del archivo""" + # Patrones comunes: "Key A", "in A", "A minor", "Am", "F#m", etc. + patterns = [ + r'[_\s\-]([A-G][#b]?m?)\s*(?:minor|major)?[_\s\-]?', + r'[_\s\-]([A-G][#b]?)[_\s\-]', + r'\bin\s+([A-G][#b]?m?)\b', + r'Key\s+([A-G][#b]?m?)', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + return key + + return None + + def _extract_bpm(self, name: str) -> Optional[int]: + """Extrae el BPM del nombre del archivo""" + # Patrones: "128 BPM", "_128_", "128bpm", etc. + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: # Rango razonable + return bpm + + return None + + def _load_index(self): + """Carga el índice desde archivo""" + try: + with open(self.index_file, 'r') as f: + data = json.load(f) + self.samples = data.get('samples', []) + logger.info(f"Índice cargado: {len(self.samples)} samples") + except Exception as e: + logger.error(f"Error cargando índice: {e}") + self._build_index() + + def _save_index(self): + """Guarda el índice a archivo""" + try: + with open(self.index_file, 'w') as f: + json.dump({ + 'samples': self.samples, + 'base_dir': str(self.base_dir) + }, f, indent=2) + logger.info(f"Índice guardado en: {self.index_file}") + except Exception as e: + logger.error(f"Error guardando índice: {e}") + + def search(self, query: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: + """ + Busca samples por query y/o categoría + + Args: + query: Término de búsqueda + category: Categoría específica (opcional) + limit: Número máximo de resultados + + Returns: + Lista de samples que coinciden + """ + query_lower = query.lower() + results = [] + + for sample in self.samples: + # Filtrar por categoría si se especificó + if category and sample['category'] != category.lower(): + continue + + # Buscar en nombre + name = sample['name'].lower() + if query_lower in name: + # Calcular score de relevancia + score = 0 + if query_lower == sample.get('category', ''): + score += 10 # Coincidencia exacta de categoría + if query_lower in name.split('_'): + score += 5 # Palabra completa + if name.startswith(query_lower): + score += 3 # Comienza con el término + + results.append((score, sample)) + + # Ordenar por score y limitar + results.sort(key=lambda x: x[0], reverse=True) + return [sample for _, sample in results[:limit]] + + def find_by_key(self, key: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: + """Busca samples por tonalidad""" + results = [] + + for sample in self.samples: + if sample.get('key') == key: + if not category or sample['category'] == category: + results.append(sample) + + return results[:limit] + + def find_by_bpm(self, bpm: int, tolerance: int = 5, limit: int = 10) -> List[Dict[str, Any]]: + """Busca samples por BPM con tolerancia""" + results = [] + + for sample in self.samples: + sample_bpm = sample.get('bpm') + if sample_bpm and abs(sample_bpm - bpm) <= tolerance: + results.append(sample) + + return results[:limit] + + def get_random_sample(self, category: str = "") -> Optional[Dict[str, Any]]: + """Obtiene un sample aleatorio, opcionalmente filtrado por categoría""" + import random + + samples = self.samples + if category: + samples = [s for s in samples if s['category'] == category] + + return random.choice(samples) if samples else None + + def get_sample_pack(self, genre: str, key: str = "", bpm: int = 0) -> Dict[str, List[Dict]]: + """ + Obtiene un pack de samples completo para un género + + Args: + genre: Género musical + key: Tonalidad preferida + bpm: BPM preferido + + Returns: + Dict con samples organizados por categoría + """ + pack = { + 'kick': [], + 'snare': [], + 'hat': [], + 'clap': [], + 'perc': [], + 'bass': [], + 'synth': [], + 'fx': [], + } + + # Seleccionar un sample de cada categoría + for category in pack.keys(): + candidates = [s for s in self.samples if s['category'] == category] + + # Filtrar por key si se especificó + if key and candidates: + key_matches = [s for s in candidates if s.get('key') == key] + if key_matches: + candidates = key_matches + + # Filtrar por BPM si se especificó + if bpm and candidates: + bpm_matches = [s for s in candidates if s.get('bpm')] + if bpm_matches: + # Ordenar por cercanía al BPM objetivo + bpm_matches.sort(key=lambda s: abs(s['bpm'] - bpm)) + candidates = bpm_matches[:5] # Top 5 más cercanos + + # Seleccionar hasta 3 samples + import random + if candidates: + pack[category] = random.sample(candidates, min(3, len(candidates))) + + return pack + + def refresh(self): + """Reconstruye el índice desde cero""" + logger.info("Refrescando índice...") + self._build_index() + self._save_index() + + +# Función de utilidad para testing +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Uso: python sample_index.py ") + sys.exit(1) + + logging.basicConfig(level=logging.INFO) + + index = SampleIndex(sys.argv[1]) + + print(f"\nÍndice cargado: {len(index.samples)} samples") + print("\nDistribución por categoría:") + + categories = {} + for sample in index.samples: + cat = sample['category'] + categories[cat] = categories.get(cat, 0) + 1 + + for cat, count in sorted(categories.items(), key=lambda x: -x[1]): + print(f" {cat}: {count}") + + # Ejemplo de búsqueda + print("\nBúsqueda 'kick':") + for s in index.search("kick", limit=5): + print(f" - {s['name']} ({s.get('key', '?')}, {s.get('bpm', '?')} BPM)") diff --git a/AbletonMCP_AI/MCP_Server/sample_manager.py b/AbletonMCP_AI/MCP_Server/sample_manager.py new file mode 100644 index 0000000..df9063a --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/sample_manager.py @@ -0,0 +1,1011 @@ +""" +sample_manager.py - Gestión completa de librería de samples + +Proporciona: +- Indexación y escaneo de directorios de samples +- Clasificación automática por tipo, key, BPM +- Gestión de metadatos y tags +- Búsqueda avanzada con filtros múltiples +- Caché de índice para rendimiento +- Soporte para múltiples formatos (WAV, AIFF, MP3, OGG, FLAC) +""" + +import json +import hashlib +import logging +from pathlib import Path +from typing import Dict, List, Any, Optional, Tuple, Callable +from dataclasses import dataclass, field, asdict +from datetime import datetime +from collections import defaultdict +import threading + +# Importar analizador de audio +try: + from .audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze + AUDIO_ANALYSIS_AVAILABLE = True +except ImportError: + try: + from audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze + AUDIO_ANALYSIS_AVAILABLE = True + except ImportError: + AUDIO_ANALYSIS_AVAILABLE = False + AudioAnalyzer = None + SampleType = None + analyze_sample = None + quick_analyze = None + +logger = logging.getLogger("SampleManager") + + +@dataclass +class Sample: + """Representa un sample en la librería""" + id: str + name: str + path: str + category: str + subcategory: str + sample_type: str + key: Optional[str] = None + bpm: Optional[float] = None + duration: float = 0.0 + sample_rate: int = 44100 + channels: int = 2 + file_size: int = 0 + format: str = "wav" + + # Metadatos adicionales + genres: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + mood: str = "" + energy: float = 0.5 # 0-1 + + # Información de análisis + analyzed: bool = False + analysis_version: int = 0 + spectral_centroid: float = 0.0 + rms_energy: float = 0.0 + is_harmonic: bool = False + is_percussive: bool = False + + # Metadatos del sistema + date_added: str = field(default_factory=lambda: datetime.now().isoformat()) + date_modified: str = field(default_factory=lambda: datetime.now().isoformat()) + play_count: int = 0 + rating: int = 0 # 0-5 + + def to_dict(self) -> Dict[str, Any]: + """Convierte el sample a diccionario""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Sample': + """Crea un Sample desde un diccionario""" + # Filtrar solo los campos que existen en la clase + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered_data = {k: v for k, v in data.items() if k in valid_fields} + return cls(**filtered_data) + + def get_display_name(self) -> str: + """Nombre formateado para mostrar""" + parts = [self.name] + if self.key: + parts.append(f"Key: {self.key}") + if self.bpm: + parts.append(f"{self.bpm:.1f} BPM") + return " | ".join(parts) + + +class SampleManager: + """ + Gestor principal de la librería de samples. + + Características: + - Indexación recursiva de directorios + - Clasificación automática por tipo + - Detección de key y BPM (si librosa está disponible) + - Búsqueda avanzada con múltiples filtros + - Sistema de favoritos y ratings + - Caché persistente en JSON + """ + + # Categorías principales y subcategorías + CATEGORIES = { + 'drums': { + 'kick': ['kick', 'bd', 'bass drum', 'kickdrum'], + 'snare': ['snare', 'snr', 'sd', 'rimshot'], + 'clap': ['clap', 'clp', 'handclap'], + 'hat_closed': ['closed hat', 'chh', 'closed'], + 'hat_open': ['open hat', 'ohh', 'open'], + 'hat': ['hat', 'hihat', 'hi-hat'], + 'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale'], + 'shaker': ['shaker', 'tambourine', 'tamb'], + 'tom': ['tom', 'tomtom'], + 'cymbal': ['crash', 'ride', 'cymbal', 'china'], + }, + 'bass': { + 'sub': ['sub', 'subbass', '808'], + 'bassline': ['bassline', 'bass', 'reese'], + 'acid': ['acid', 'tb303', '303'], + }, + 'synths': { + 'lead': ['lead', 'solo', 'main'], + 'pad': ['pad', 'atmosphere', 'dron', 'ambient'], + 'pluck': ['pluck', 'arp', 'arpeggio'], + 'chord': ['chord', 'stab', 'hit'], + 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter'], + }, + 'vocals': { + 'vocal': ['vocal', 'vox', 'voice'], + 'speech': ['speech', 'talk', 'phrase'], + 'chant': ['chant', 'shout', 'yell'], + }, + 'loops': { + 'drum_loop': ['drum loop', 'beat loop', 'groove'], + 'perc_loop': ['perc loop', 'percussion loop'], + 'bass_loop': ['bass loop', 'bassline loop'], + 'synth_loop': ['synth loop', 'lead loop'], + 'full_loop': ['full loop', 'complete loop'], + }, + 'one_shots': { + 'hit': ['hit', 'impact', 'sting'], + 'noise': ['noise', 'texture', 'grain'], + } + } + + # Mapeo de extensiones de archivo + SUPPORTED_FORMATS = {'.wav', '.aif', '.aiff', '.mp3', '.ogg', '.flac', '.m4a'} + + # Géneros soportados con palabras clave + GENRE_KEYWORDS = { + 'house': ['house', 'deep', 'soulful', 'garage', 'classic'], + 'techno': ['techno', 'industrial', 'detroit', 'berlin', 'acid'], + 'tech-house': ['tech house', 'tech-house', 'groovy', 'bouncy'], + 'trance': ['trance', 'progressive', 'uplifting', 'psy'], + 'drum-and-bass': ['drum and bass', 'dnb', 'neuro', 'liquid', 'jungle'], + 'hip-hop': ['hip hop', 'hiphop', 'trap', 'boom bap', 'lofi'], + 'ambient': ['ambient', 'chillout', 'downtempo', 'meditation'], + 'edm': ['edm', 'electro', 'big room', 'festival'], + } + + def __init__(self, base_dir: str, cache_dir: Optional[str] = None): + """ + Inicializa el gestor de samples. + + Args: + base_dir: Directorio raíz de la librería de samples + cache_dir: Directorio para caché (default: base_dir/.sample_cache) + """ + self.base_dir = Path(base_dir) + self.cache_dir = Path(cache_dir) if cache_dir else self.base_dir / ".sample_cache" + self.cache_dir.mkdir(exist_ok=True) + + self.samples: Dict[str, Sample] = {} + self.index_file = self.cache_dir / "sample_library.json" + self.stats_file = self.cache_dir / "library_stats.json" + + # Analizador de audio + self.analyzer = AudioAnalyzer() if AUDIO_ANALYSIS_AVAILABLE else None + + # Locks para thread-safety + self._lock = threading.RLock() + self._index_dirty = False + + # Estadísticas + self.stats = { + 'total_samples': 0, + 'total_size': 0, + 'by_category': defaultdict(int), + 'by_key': defaultdict(int), + 'by_bpm_range': defaultdict(int), + 'last_scan': None, + } + + # Cargar índice existente + self._load_index() + + def _generate_id(self, file_path: str) -> str: + """Genera un ID único para un sample basado en su ruta""" + return hashlib.md5(file_path.encode()).hexdigest()[:16] + + def _get_file_hash(self, file_path: Path) -> str: + """Calcula hash del archivo para detectar cambios""" + stat = file_path.stat() + return hashlib.md5(f"{stat.st_size}_{stat.st_mtime}".encode()).hexdigest() + + def scan_directory(self, directory: Optional[str] = None, + recursive: bool = True, + analyze_audio: bool = False, + progress_callback: Optional[Callable[[int, int, str], None]] = None) -> Dict[str, Any]: + """ + Escanear un directorio en busca de samples. + + Args: + directory: Directorio a escanear (default: base_dir) + recursive: Escanear subdirectorios + analyze_audio: Analizar contenido de audio (más lento) + progress_callback: Función llamada con (procesados, total, archivo_actual) + + Returns: + Estadísticas del escaneo + """ + scan_dir = Path(directory) if directory else self.base_dir + + if not scan_dir.exists(): + raise FileNotFoundError(f"Directorio no encontrado: {scan_dir}") + + logger.info(f"Escaneando: {scan_dir}") + + # Encontrar todos los archivos de audio + if recursive: + audio_files = list(scan_dir.rglob('*')) + else: + audio_files = list(scan_dir.iterdir()) + + audio_files = [f for f in audio_files + if f.is_file() and f.suffix.lower() in self.SUPPORTED_FORMATS] + + total = len(audio_files) + processed = 0 + added = 0 + updated = 0 + errors = 0 + + logger.info(f"Encontrados {total} archivos de audio") + + with self._lock: + for file_path in audio_files: + processed += 1 + + if progress_callback: + progress_callback(processed, total, str(file_path.name)) + + try: + result = self._process_file(file_path, analyze_audio) + if result == 'added': + added += 1 + elif result == 'updated': + updated += 1 + + except Exception as e: + logger.error(f"Error procesando {file_path}: {e}") + errors += 1 + + self._index_dirty = True + self._update_stats() + self._save_index() + + self.stats['last_scan'] = datetime.now().isoformat() + + return { + 'processed': processed, + 'added': added, + 'updated': updated, + 'errors': errors, + 'total_samples': len(self.samples), + } + + def _process_file(self, file_path: Path, analyze_audio: bool) -> str: + """Procesa un archivo individual. Retorna 'added', 'updated', o 'unchanged'""" + file_id = self._generate_id(str(file_path)) + self._get_file_hash(file_path) + + # Verificar si ya existe y no ha cambiado + if file_id in self.samples: + existing = self.samples[file_id] + # Comparar hash implícito por fecha de modificación + current_stat = file_path.stat() + if existing.date_modified: + try: + mod_time = datetime.fromisoformat(existing.date_modified).timestamp() + if abs(current_stat.st_mtime - mod_time) < 1: + return 'unchanged' + except Exception: + pass + + # Extraer información del nombre + name = file_path.stem + category, subcategory = self._classify_by_name(name) + sample_type = self._detect_sample_type(name) + key = self._extract_key_from_name(name) + bpm = self._extract_bpm_from_name(name) + genres = self._detect_genres(name) + + # Análisis de audio si está disponible + audio_features = {} + if analyze_audio and self.analyzer: + try: + audio_features = analyze_sample(str(file_path)) + # Usar valores detectados si no están en el nombre + if not bpm and audio_features.get('bpm'): + bpm = audio_features['bpm'] + if not key and audio_features.get('key'): + key = audio_features['key'] + if audio_features.get('sample_type'): + sample_type = audio_features['sample_type'] + if audio_features.get('suggested_genres'): + genres = list(set(genres + audio_features['suggested_genres'])) + except Exception as e: + logger.warning(f"Error analizando audio {file_path}: {e}") + + # Crear o actualizar sample + is_new = file_id not in self.samples + + sample = Sample( + id=file_id, + name=name, + path=str(file_path), + category=category, + subcategory=subcategory, + sample_type=sample_type, + key=key, + bpm=bpm, + duration=audio_features.get('duration', 0.0), + sample_rate=audio_features.get('sample_rate', 44100), + file_size=file_path.stat().st_size, + format=file_path.suffix.lower().lstrip('.'), + genres=genres, + tags=self._extract_tags(name), + analyzed=analyze_audio, + spectral_centroid=audio_features.get('spectral_centroid', 0.0), + rms_energy=audio_features.get('rms_energy', 0.0), + is_harmonic=audio_features.get('is_harmonic', False), + is_percussive=audio_features.get('is_percussive', False), + date_modified=datetime.now().isoformat(), + ) + + self.samples[file_id] = sample + return 'added' if is_new else 'updated' + + def _classify_by_name(self, name: str) -> Tuple[str, str]: + """Clasifica un sample por su nombre en categoría y subcategoría""" + name_lower = name.lower() + + for category, subcategories in self.CATEGORIES.items(): + for subcategory, keywords in subcategories.items(): + for keyword in keywords: + if keyword in name_lower: + return category, subcategory + + # Fallback: intentar detectar loops + if 'loop' in name_lower: + return 'loops', 'unknown' + + return 'unknown', 'unknown' + + def _detect_sample_type(self, name: str) -> str: + """Detecta el tipo específico de sample""" + category, subcategory = self._classify_by_name(name) + + if category == 'drums': + return subcategory + elif category == 'bass': + return f"bass_{subcategory}" + elif category == 'synths': + return subcategory + elif category == 'vocals': + return subcategory + elif category == 'loops': + return subcategory + + return 'unknown' + + def _extract_key_from_name(self, name: str) -> Optional[str]: + """Extrae la tonalidad del nombre del archivo""" + import re + + # Patrones comunes + patterns = [ + r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', + r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', + r'Key[_\s]?([A-G][#b]?(?:m|min|minor)?)', + r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar bemoles a sostenidos + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + # Detectar modo + is_minor = 'm' in key.lower() or 'min' in key.lower() + key = key.replace('min', '').replace('minor', '').replace('major', '') + key = key.rstrip('mM') + + if is_minor: + key = key + 'm' + + return key + + return None + + def _extract_bpm_from_name(self, name: str) -> Optional[float]: + """Extrae el BPM del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: + return float(bpm) + + return None + + def _detect_genres(self, name: str) -> List[str]: + """Detecta géneros musicales del nombre""" + name_lower = name.lower() + genres = [] + + for genre, keywords in self.GENRE_KEYWORDS.items(): + for keyword in keywords: + if keyword in name_lower: + genres.append(genre) + break + + return genres + + def _extract_tags(self, name: str) -> List[str]: + """Extrae tags del nombre del archivo""" + import re + + tags = [] + name_lower = name.lower() + + # Palabras comunes como tags + common_tags = [ + 'dry', 'wet', 'processed', 'raw', 'analog', 'digital', + 'vintage', 'modern', 'punchy', 'deep', 'bright', 'dark', + 'tight', 'loose', 'fat', 'thin', 'crisp', 'warm', + 'one shot', 'loop', 'sample', 'hit' + ] + + for tag in common_tags: + if tag in name_lower: + tags.append(tag.replace(' ', '_')) + + # Extraer números como versiones + numbers = re.findall(r'\d+', name) + for num in numbers: + if len(num) <= 2: # Probablemente versión + tags.append(f"v{num}") + + return list(set(tags)) + + def search(self, + query: str = "", + category: str = "", + subcategory: str = "", + sample_type: str = "", + key: str = "", + bpm: Optional[float] = None, + bpm_tolerance: int = 5, + genres: List[str] = None, + tags: List[str] = None, + min_rating: int = 0, + favorites_only: bool = False, + limit: int = 50, + sort_by: str = "name") -> List[Sample]: + """ + Búsqueda avanzada de samples con múltiples filtros. + + Args: + query: Búsqueda por nombre + category: Categoría principal + subcategory: Subcategoría + sample_type: Tipo específico + key: Tonalidad musical + bpm: BPM objetivo + bpm_tolerance: Tolerancia de BPM (+/-) + genres: Lista de géneros + tags: Lista de tags + min_rating: Rating mínimo + favorites_only: Solo favoritos + limit: Límite de resultados + sort_by: Campo para ordenar + + Returns: + Lista de samples que coinciden + """ + with self._lock: + results = [] + query_lower = query.lower() + + for sample in self.samples.values(): + # Filtro por query (nombre) + if query and query_lower not in sample.name.lower(): + continue + + # Filtros de categoría + if category and sample.category != category.lower(): + continue + if subcategory and sample.subcategory != subcategory.lower(): + continue + if sample_type and sample.sample_type != sample_type.lower(): + continue + + # Filtro por key + if key: + sample_key = (sample.key or "").lower() + if sample_key != key.lower(): + # Intentar key compatible (mismo root) + if not sample_key.startswith(key.lower().rstrip('m')): + continue + + # Filtro por BPM + if bpm is not None and sample.bpm: + if abs(sample.bpm - bpm) > bpm_tolerance: + continue + + # Filtro por géneros + if genres: + sample_genres = [g.lower() for g in sample.genres] + if not any(g.lower() in sample_genres for g in genres): + continue + + # Filtro por tags + if tags: + sample_tags = [t.lower() for t in sample.tags] + if not any(t.lower() in sample_tags for t in tags): + continue + + # Filtro por rating + if min_rating > 0 and sample.rating < min_rating: + continue + + # Filtro de favoritos + if favorites_only and sample.rating < 4: + continue + + results.append(sample) + + # Ordenar resultados + if sort_by == "name": + results.sort(key=lambda s: s.name.lower()) + elif sort_by == "bpm": + results.sort(key=lambda s: s.bpm or 0) + elif sort_by == "rating": + results.sort(key=lambda s: s.rating, reverse=True) + elif sort_by == "date_added": + results.sort(key=lambda s: s.date_added, reverse=True) + + return results[:limit] + + def get_by_id(self, sample_id: str) -> Optional[Sample]: + """Obtiene un sample por su ID""" + with self._lock: + return self.samples.get(sample_id) + + def get_by_path(self, file_path: str) -> Optional[Sample]: + """Obtiene un sample por su ruta""" + sample_id = self._generate_id(file_path) + return self.get_by_id(sample_id) + + def get_random(self, category: str = "", limit: int = 1) -> List[Sample]: + """Obtiene samples aleatorios""" + import random + + with self._lock: + samples = list(self.samples.values()) + + if category: + samples = [s for s in samples if s.category == category] + + if not samples: + return [] + + return random.sample(samples, min(limit, len(samples))) + + def get_pack_for_genre(self, genre: str, key: str = "", + bpm: Optional[float] = None) -> Dict[str, List[Sample]]: + """ + Obtiene un pack completo de samples para un género específico. + + Returns: + Dict con samples organizados por tipo + """ + pack = { + 'kicks': [], + 'snares': [], + 'claps': [], + 'hats': [], + 'percussion': [], + 'bass': [], + 'synths': [], + 'fx': [], + } + + # Buscar samples por tipo + type_mapping = { + 'kicks': ['kick'], + 'snares': ['snare'], + 'claps': ['clap'], + 'hats': ['hat', 'hat_closed', 'hat_open'], + 'percussion': ['perc', 'shaker', 'tom', 'cymbal'], + 'bass': ['bass', 'sub', 'bassline', 'acid'], + 'synths': ['lead', 'pad', 'pluck', 'chord'], + 'fx': ['fx', 'hit', 'noise'], + } + + for pack_category, sample_types in type_mapping.items(): + for sample_type in sample_types: + samples = self.search( + sample_type=sample_type, + key=key, + bpm=bpm, + genres=[genre] if genre else None, + limit=5 + ) + + if samples: + pack[pack_category].extend(samples) + + return pack + + def update_sample(self, sample_id: str, **kwargs) -> bool: + """ + Actualiza metadatos de un sample. + + Args: + sample_id: ID del sample + **kwargs: Campos a actualizar + """ + with self._lock: + if sample_id not in self.samples: + return False + + sample = self.samples[sample_id] + + # Campos permitidos para actualización + allowed_fields = { + 'rating', 'tags', 'genres', 'mood', 'energy', + 'key', 'bpm', 'play_count' + } + + for field, value in kwargs.items(): + if field in allowed_fields and hasattr(sample, field): + setattr(sample, field, value) + + sample.date_modified = datetime.now().isoformat() + self._index_dirty = True + + return True + + def rate_sample(self, sample_id: str, rating: int) -> bool: + """Califica un sample (1-5 estrellas)""" + if 0 <= rating <= 5: + return self.update_sample(sample_id, rating=rating) + return False + + def increment_play_count(self, sample_id: str) -> bool: + """Incrementa el contador de reproducciones""" + sample = self.get_by_id(sample_id) + if sample: + return self.update_sample(sample_id, play_count=sample.play_count + 1) + return False + + def delete_sample(self, sample_id: str, delete_file: bool = False) -> bool: + """ + Elimina un sample del índice. + + Args: + sample_id: ID del sample + delete_file: Si True, también elimina el archivo físico + """ + with self._lock: + if sample_id not in self.samples: + return False + + sample = self.samples[sample_id] + + if delete_file: + try: + Path(sample.path).unlink() + except Exception as e: + logger.error(f"Error eliminando archivo: {e}") + return False + + del self.samples[sample_id] + self._index_dirty = True + self._update_stats() + + return True + + def refresh(self, analyze_audio: bool = False) -> Dict[str, Any]: + """Refresca el índice completo""" + logger.info("Refrescando índice de samples...") + + # Guardar IDs actuales para detectar eliminados + current_paths = {s.path for s in self.samples.values()} + + # Re-escanear + stats = self.scan_directory(analyze_audio=analyze_audio) + + # Detectar archivos eliminados + new_paths = {s.path for s in self.samples.values()} + removed = current_paths - new_paths + + for path in removed: + sample_id = self._generate_id(path) + if sample_id in self.samples: + del self.samples[sample_id] + stats['removed'] = stats.get('removed', 0) + 1 + + self._save_index() + return stats + + def get_stats(self) -> Dict[str, Any]: + """Obtiene estadísticas de la librería""" + with self._lock: + return { + 'total_samples': len(self.samples), + 'total_size': sum(s.file_size for s in self.samples.values()), + 'by_category': dict(self.stats['by_category']), + 'by_key': dict(self.stats['by_key']), + 'by_bpm_range': dict(self.stats['by_bpm_range']), + 'last_scan': self.stats['last_scan'], + } + + def export_library(self, output_path: str, format: str = "json") -> str: + """ + Exporta la librería a un archivo. + + Args: + output_path: Ruta del archivo de salida + format: 'json' o 'csv' + + Returns: + Ruta del archivo exportado + """ + output = Path(output_path) + + with self._lock: + if format == "json": + data = { + 'export_date': datetime.now().isoformat(), + 'stats': self.get_stats(), + 'samples': [s.to_dict() for s in self.samples.values()] + } + with open(output, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + elif format == "csv": + import csv + with open(output, 'w', newline='', encoding='utf-8') as f: + if self.samples: + writer = csv.DictWriter(f, fieldnames=self.samples[list(self.samples.keys())[0]].to_dict().keys()) + writer.writeheader() + for sample in self.samples.values(): + writer.writerow(sample.to_dict()) + + return str(output) + + def import_library(self, input_path: str, merge: bool = True) -> Dict[str, int]: + """ + Importa una librería desde un archivo JSON. + + Args: + input_path: Ruta del archivo a importar + merge: Si True, mezcla con la librería existente + + Returns: + Estadísticas de la importación + """ + with open(input_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + imported_samples = data.get('samples', []) + + with self._lock: + if not merge: + self.samples.clear() + + added = 0 + updated = 0 + + for sample_data in imported_samples: + try: + sample = Sample.from_dict(sample_data) + if sample.id in self.samples: + updated += 1 + else: + added += 1 + self.samples[sample.id] = sample + except Exception as e: + logger.error(f"Error importando sample: {e}") + + self._index_dirty = True + self._update_stats() + self._save_index() + + return {'added': added, 'updated': updated} + + def _update_stats(self): + """Actualiza las estadísticas de la librería""" + self.stats['total_samples'] = len(self.samples) + self.stats['total_size'] = sum(s.file_size for s in self.samples.values()) + + # Resetear contadores + self.stats['by_category'] = defaultdict(int) + self.stats['by_key'] = defaultdict(int) + self.stats['by_bpm_range'] = defaultdict(int) + + for sample in self.samples.values(): + self.stats['by_category'][sample.category] += 1 + + if sample.key: + self.stats['by_key'][sample.key] += 1 + + if sample.bpm: + if sample.bpm < 100: + self.stats['by_bpm_range']['slow (<100)'] += 1 + elif sample.bpm < 128: + self.stats['by_bpm_range']['mid (100-128)'] += 1 + elif sample.bpm < 140: + self.stats['by_bpm_range']['fast (128-140)'] += 1 + else: + self.stats['by_bpm_range']['very fast (>140)'] += 1 + + def _load_index(self): + """Carga el índice desde disco""" + if not self.index_file.exists(): + logger.info("No existe índice previo, iniciando librería vacía") + return + + try: + with open(self.index_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + for sample_data in data.get('samples', []): + try: + sample = Sample.from_dict(sample_data) + self.samples[sample.id] = sample + except Exception as e: + logger.warning(f"Error cargando sample: {e}") + + self.stats = data.get('stats', self.stats) + logger.info(f"Índice cargado: {len(self.samples)} samples") + + except Exception as e: + logger.error(f"Error cargando índice: {e}") + + def _save_index(self): + """Guarda el índice a disco""" + if not self._index_dirty: + return + + try: + data = { + 'version': 1, + 'saved_at': datetime.now().isoformat(), + 'stats': self.get_stats(), + 'samples': [s.to_dict() for s in self.samples.values()] + } + + # Guardar a archivo temporal primero + temp_file = self.index_file.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Renombrar atómicamente + temp_file.replace(self.index_file) + + self._index_dirty = False + logger.info(f"Índice guardado: {len(self.samples)} samples") + + except Exception as e: + logger.error(f"Error guardando índice: {e}") + + def save(self): + """Fuerza el guardado del índice""" + self._index_dirty = True + self._save_index() + + +# Instancia global +_manager: Optional[SampleManager] = None + + +def get_manager(base_dir: Optional[str] = None) -> SampleManager: + """Obtiene la instancia global del gestor""" + global _manager + if _manager is None: + if base_dir is None: + # FIX: Use absolute path to avoid junction/hardlink issues + PROGRAM_DATA_DIR = Path("C:/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts") + base_dir = str(PROGRAM_DATA_DIR / "librerias" / "all_tracks") + _manager = SampleManager(base_dir) + return _manager + + +# Funciones de conveniencia +def scan_samples(directory: str, analyze_audio: bool = False) -> Dict[str, Any]: + """Escanear directorio de samples""" + manager = get_manager(directory) + return manager.scan_directory(analyze_audio=analyze_audio) + + +def find_samples(query: str = "", **kwargs) -> List[Dict[str, Any]]: + """Buscar samples""" + manager = get_manager() + samples = manager.search(query=query, **kwargs) + return [s.to_dict() for s in samples] + + +def get_sample_pack(genre: str, key: str = "", bpm: Optional[float] = None) -> Dict[str, List[Dict]]: + """Obtener pack de samples para un género""" + manager = get_manager() + pack = manager.get_pack_for_genre(genre, key, bpm) + return {k: [s.to_dict() for s in v] for k, v in pack.items()} + + +# Testing +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO) + + if len(sys.argv) < 2: + print("Uso: python sample_manager.py [comando]") + print("\nComandos:") + print(" scan - Escanear directorio") + print(" stats - Mostrar estadísticas") + print(" search - Buscar samples") + sys.exit(1) + + directory = sys.argv[1] + command = sys.argv[2] if len(sys.argv) > 2 else "scan" + + manager = SampleManager(directory) + + if command == "scan": + print(f"\nEscaneando: {directory}") + print("=" * 50) + + def progress(current, total, filename): + pct = (current / total) * 100 + print(f"\r[{pct:5.1f}%] {filename[:50]:<50}", end="", flush=True) + + stats = manager.scan_directory(progress_callback=progress) + print("\n") + print(f"Procesados: {stats['processed']}") + print(f"Agregados: {stats['added']}") + print(f"Actualizados: {stats['updated']}") + print(f"Errores: {stats['errors']}") + print(f"Total en librería: {stats['total_samples']}") + + elif command == "stats": + stats = manager.get_stats() + print("\nEstadísticas de la librería:") + print("=" * 50) + print(f"Total samples: {stats['total_samples']}") + print(f"Tamaño total: {stats['total_size'] / (1024**2):.1f} MB") + print(f"Último escaneo: {stats['last_scan']}") + print("\nPor categoría:") + for cat, count in sorted(stats['by_category'].items()): + print(f" {cat}: {count}") + print("\nPor key:") + for key, count in sorted(stats['by_key'].items()): + print(f" {key}: {count}") + + elif command == "search": + query = sys.argv[3] if len(sys.argv) > 3 else "" + print(f"\nBuscando: '{query}'") + print("=" * 50) + + results = manager.search(query=query, limit=20) + for s in results: + print(f"\n{s.name}") + print(f" Categoría: {s.category}/{s.subcategory}") + print(f" Key: {s.key or 'N/A'} | BPM: {s.bpm or 'N/A'}") + print(f" Path: {s.path}") diff --git a/AbletonMCP_AI/MCP_Server/sample_selector.py b/AbletonMCP_AI/MCP_Server/sample_selector.py new file mode 100644 index 0000000..a664f1e --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/sample_selector.py @@ -0,0 +1,2640 @@ +""" +sample_selector.py - Selector inteligente de samples (Fase 4 mejorada) + +Proporciona: +- Selección contextual basada en género, key, BPM +- Matching armónico entre samples +- Creación de kits de batería coherentes +- Recomendaciones basadas en compatibilidad +- Mapeo MIDI automático + +Mejoras Fase 4: +- Ranking mejorado con múltiples factores de similitud +- Diversidad entre corridas con seeding determinista +- Validación de roles para evitar elecciones absurdas +- Penalización de familias repetidas +- Balance one-shots vs loops +- Soporte opcional para GPU/embeddings +""" + +import random +import logging +import hashlib +import time +from typing import Dict, List, Any, Optional, Tuple +from dataclasses import dataclass, field +from collections import defaultdict, deque + +# Detección de numpy para cálculos vectorizados +try: + import numpy as np + NUMPY_AVAILABLE = True +except ImportError: + NUMPY_AVAILABLE = False + np = None + +# Detección de GPU (cupy) para aceleración +try: + import cupy as cp + GPU_AVAILABLE = True +except ImportError: + GPU_AVAILABLE = False + cp = None + +# Imports del sistema de samples +try: + from .sample_manager import SampleManager, Sample, get_manager + from .audio_analyzer import AudioAnalyzer, calculate_key_compatibility + MANAGER_AVAILABLE = True +except ImportError: + try: + from sample_manager import SampleManager, Sample, get_manager + from audio_analyzer import AudioAnalyzer, calculate_key_compatibility + MANAGER_AVAILABLE = True + except ImportError: + MANAGER_AVAILABLE = False + SampleManager = None + Sample = None + AudioAnalyzer = None + calculate_key_compatibility = None + +logger = logging.getLogger("SampleSelector") + +# ============================================================================ +# IMPORTS DE MEMORIA DE DIVERSIDAD (Phase 5) +# ============================================================================ +try: + from .diversity_memory import ( + get_diversity_memory, + record_sample_usage, + record_generation_complete, + get_penalty_for_sample, + detect_sample_family, + DIVERSITY_MEMORY_AVAILABLE + ) + DIVERSITY_MEMORY_AVAILABLE = True +except ImportError: + try: + from diversity_memory import ( + get_diversity_memory, + record_sample_usage, + record_generation_complete, + get_penalty_for_sample, + detect_sample_family, + ) + DIVERSITY_MEMORY_AVAILABLE = True + except ImportError: + DIVERSITY_MEMORY_AVAILABLE = False + get_diversity_memory = None + record_sample_usage = None + record_generation_complete = None + get_penalty_for_sample = None + detect_sample_family = None + +# Memoria entre generaciones (legacy, mantener para compatibilidad) +# Ahora delegamos a diversity_memory.py para persistencia +_cross_generation_family_memory: Dict[str, int] = defaultdict(int) +_cross_generation_path_memory: Dict[str, int] = defaultdict(int) +_cross_generation_generation_count: int = 0 + +_recent_sample_diversity_memory: Dict[str, List[str]] = defaultdict(list) +RECENT_MEMORY_MAX_PER_ROLE = 50 + +def _get_cross_generation_memory() -> Dict[str, int]: + """Retorna copia de la memoria entre generaciones.""" + return _cross_generation_family_memory.copy() + +def _update_cross_generation_memory(families_used: Dict[str, int], paths_used: List[str] = None) -> None: + """Actualiza memoria cross-generation con familias y paths usados. + + Esta función ahora delega principalmente a diversity_memory.py para + persistencia persistente, pero mantiene la memoria en memoria para + compatibilidad con código existente. + """ + global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count + _cross_generation_generation_count += 1 + + # Delegar al sistema de memoria persistente + if DIVERSITY_MEMORY_AVAILABLE: + try: + record_generation_complete() + logger.debug("Memoria cross-generation persistida (generación %d)", _cross_generation_generation_count) + except Exception as e: + logger.warning("Error actualizando memoria persistente: %s", e) + + # Mantener memoria en RAM para compatibilidad + for family in list(_cross_generation_family_memory.keys()): + _cross_generation_family_memory[family] = max(0, _cross_generation_family_memory[family] - 1) + + for path in list(_cross_generation_path_memory.keys()): + _cross_generation_path_memory[path] = max(0, _cross_generation_path_memory[path] - 1) + + for family, count in families_used.items(): + _cross_generation_family_memory[family] += count + + if paths_used: + for path in paths_used: + _cross_generation_path_memory[path] += 1 + + _cross_generation_family_memory = {k: v for k, v in _cross_generation_family_memory.items() if v > 0} + _cross_generation_path_memory = {k: v for k, v in _cross_generation_path_memory.items() if v > 0} + +def reset_cross_generation_memory() -> None: + """Limpia toda la memoria cross-generation (RAM y persistente).""" + global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count, _recent_sample_diversity_memory + + # Limpiar memoria persistente + if DIVERSITY_MEMORY_AVAILABLE: + try: + from .diversity_memory import reset_diversity_memory + reset_diversity_memory() + logger.info("Memoria de diversidad persistente reseteada") + except ImportError: + try: + from diversity_memory import reset_diversity_memory + reset_diversity_memory() + logger.info("Memoria de diversidad persistente reseteada") + except ImportError: + pass + + # Limpiar memoria en RAM + _cross_generation_family_memory.clear() + _cross_generation_path_memory.clear() + _cross_generation_generation_count = 0 + _recent_sample_diversity_memory.clear() + +def add_to_recent_memory(role: str, sample_path: str) -> None: + """Add a sample path to the recent memory for its role.""" + global _recent_sample_diversity_memory + if role not in _recent_sample_diversity_memory: + _recent_sample_diversity_memory[role] = [] + if sample_path not in _recent_sample_diversity_memory[role]: + _recent_sample_diversity_memory[role].append(sample_path) + if len(_recent_sample_diversity_memory[role]) > RECENT_MEMORY_MAX_PER_ROLE: + _recent_sample_diversity_memory[role] = _recent_sample_diversity_memory[role][-RECENT_MEMORY_MAX_PER_ROLE:] + +def get_recent_memory_penalty(role: str, sample_path: str) -> float: + """Get penalty for a sample that was recently used for the same role.Returns 1.0 (no penalty) to 0.1 (strong penalty).""" + global _recent_sample_diversity_memory + role_samples = _recent_sample_diversity_memory.get(role, []) + if sample_path not in role_samples: + return 1.0 + position = role_samples.index(sample_path) + recency = len(role_samples) - position + if recency <= 5: + return 0.1 + elif recency <= 10: + return 0.25 + elif recency <= 20: + return 0.5 + elif recency <= 30: + return 0.7 + else: + return 0.85 + +def get_recent_sample_diversity_state() -> Dict[str, List[str]]: + """Get copy of recent sample diversity memory.""" + return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()} + +def sync_cross_generation_memory_from_reference(families: Dict[str, int], paths: Dict[str, int]) -> None: + """Sincroniza memoria cross-generation con reference_listener (para consistencia).""" + global _cross_generation_family_memory, _cross_generation_path_memory + for family, count in families.items(): + if count > 0: + _cross_generation_family_memory[family] = max( + _cross_generation_family_memory.get(family, 0), count + ) + for path, count in paths.items(): + if count > 0: + _cross_generation_path_memory[path] = max( + _cross_generation_path_memory.get(path, 0), count + ) + +def get_cross_generation_state() -> Tuple[Dict[str, int], Dict[str, int]]: + """Retorna la memoria cross-generation actual (familias, paths).""" + return ( + dict(_cross_generation_family_memory), + dict(_cross_generation_path_memory) + ) + + +@dataclass +class SampleDecision: + """Registro estructurado de decisión de selección de sample.""" + sample_name: str + target_role: str + final_score: float + selected: bool + rejection_reasons: list[str] = field(default_factory=list) + bonus_factors: list[str] = field(default_factory=list) + selection_index: int = -1 # Position in ranking + + def to_log_str(self) -> str: + """Genera string loggable.""" + if self.selected: + bonuses = ", ".join(self.bonus_factors) if self.bonus_factors else "none" + return f"SELECTED: {self.sample_name} for {self.target_role} (score={self.final_score:.3f}, bonuses={bonuses})" + else: + reasons = ", ".join(self.rejection_reasons) if self.rejection_reasons else "low score" + return f"REJECTED: {self.sample_name} for {self.target_role} ({reasons})" + + +class GenreProfile: + """Perfil musical para un género específico""" + + def __init__(self, + name: str, + bpm_range: Tuple[int, int], + common_keys: List[str], + drum_pattern: str, + bass_style: str, + characteristics: List[str]): + self.name = name + self.bpm_range = bpm_range + self.common_keys = common_keys + self.drum_pattern = drum_pattern + self.bass_style = bass_style + self.characteristics = characteristics + + +# Perfiles de géneros musicales +GENRE_PROFILES = { + 'techno': GenreProfile( + name='Techno', + bpm_range=(125, 140), + common_keys=['F#m', 'Am', 'Dm', 'Gm', 'Cm'], + drum_pattern='four_on_floor', + bass_style='rolling', + characteristics=['driving', 'industrial', 'repetitive', 'dark'] + ), + 'industrial-techno': GenreProfile( + name='Industrial Techno', + bpm_range=(135, 150), + common_keys=['F#m', 'Am', 'Dm'], + drum_pattern='distorted_four', + bass_style='aggressive', + characteristics=['distorted', 'harsh', 'mechanical', 'dark'] + ), + 'minimal-techno': GenreProfile( + name='Minimal Techno', + bpm_range=(124, 130), + common_keys=['F#m', 'Am', 'Em'], + drum_pattern='sparse', + bass_style='minimal', + characteristics=['stripped', 'subtle', 'groove', 'reduced'] + ), + 'house': GenreProfile( + name='House', + bpm_range=(118, 128), + common_keys=['Am', 'Fm', 'Cm', 'Gm', 'Dm'], + drum_pattern='classic_house', + bass_style='funky', + characteristics=['soulful', 'groovy', 'warm', 'organic'] + ), + 'deep-house': GenreProfile( + name='Deep House', + bpm_range=(120, 124), + common_keys=['Am', 'Fm', 'Dm', 'Gm'], + drum_pattern='deep_house', + bass_style='subby', + characteristics=['deep', 'jazzy', 'warm', 'mellow'] + ), + 'tech-house': GenreProfile( + name='Tech House', + bpm_range=(124, 128), + common_keys=['F#m', 'Am', 'Gm', 'Cm'], + drum_pattern='bouncy', + bass_style='groovy', + characteristics=['bouncy', 'funky', 'percussive', 'club'] + ), + 'progressive-house': GenreProfile( + name='Progressive House', + bpm_range=(126, 132), + common_keys=['Fm', 'Am', 'Dm', 'Gm'], + drum_pattern='progressive', + bass_style='driving', + characteristics=['epic', 'buildup', 'melodic', 'anthem'] + ), + 'trance': GenreProfile( + name='Trance', + bpm_range=(135, 150), + common_keys=['Fm', 'Am', 'Dm', 'Gm'], + drum_pattern='trance', + bass_style='rolling', + characteristics=['euphoric', 'melodic', 'uplifting', 'energetic'] + ), + 'psytrance': GenreProfile( + name='Psytrance', + bpm_range=(140, 150), + common_keys=['Fm', 'Gm', 'Am'], + drum_pattern='psy', + bass_style='acid', + characteristics=['psychedelic', 'acid', 'complex', 'trippy'] + ), + 'drum-and-bass': GenreProfile( + name='Drum & Bass', + bpm_range=(160, 180), + common_keys=['Am', 'Fm', 'Dm', 'Gm'], + drum_pattern='breakbeat', + bass_style='reese', + characteristics=['fast', 'heavy', 'complex', 'energetic'] + ), + 'liquid-dnb': GenreProfile( + name='Liquid Drum & Bass', + bpm_range=(168, 174), + common_keys=['Am', 'Fm', 'Dm'], + drum_pattern='liquid', + bass_style='musical', + characteristics=['smooth', 'soulful', 'melodic', 'rolling'] + ), + 'ambient': GenreProfile( + name='Ambient', + bpm_range=(80, 110), + common_keys=['C', 'Dm', 'Am', 'Em'], + drum_pattern='none', + bass_style='droning', + characteristics=['atmospheric', 'textural', 'slow', 'ethereal'] + ), +} + + +# ============================================================================ +# MAPEO DE ROLES VALIDOS - Evita elecciones absurdas +# ============================================================================ +# Define qué tipos de samples son válidos para cada rol de drum +DRUM_ROLE_VALID_TYPES = { + 'kick': ['kick', 'bd', 'bass_drum', 'kickdrum', '808'], + 'snare': ['snare', 'snr', 'sd', 'rimshot', 'rim'], + 'clap': ['clap', 'clp', 'handclap'], + 'hat_closed': ['hat_closed', 'closed_hat', 'chh', 'hihat', 'hat'], + 'hat_open': ['hat_open', 'open_hat', 'ohh', 'hihat'], + 'hat_pedal': ['hat_pedal', 'pedal_hat', 'hihat'], + 'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale', 'tamb', 'shaker'], + 'tom': ['tom', 'tomtom'], + 'crash': ['crash', 'cymbal', 'china'], + 'ride': ['ride', 'cymbal', 'ride_bell'], +} + +# Mapeo inverso: dado un sample_type, qué roles puede ocupar +SAMPLE_TYPE_TO_ROLES = defaultdict(list) +for role, valid_types in DRUM_ROLE_VALID_TYPES.items(): + for stype in valid_types: + SAMPLE_TYPE_TO_ROLES[stype].append(role) + +# Cooldown: families no se reusarán hasta después de N selecciones +COOLDOWN_WINDOW = 10 # Numero de selecciones antes de que una familia pueda reutilizarse + +# Familias de samples para penalización de repeticiones +SAMPLE_FAMILIES = { + # Drums - por fabricante/estilo + '808': ['808', 'tr808', 'tr-808'], + '909': ['909', 'tr909', 'tr-909'], + 'acoustic': ['acoustic', 'real', 'live', 'studio'], + 'electronic': ['electronic', 'digital', 'synthetic', 'synth'], + 'vintage': ['vintage', 'classic', 'old', 'retro'], + 'modern': ['modern', 'contemporary', 'new'], + # Bass - por tipo + 'sub': ['sub', 'subby', 'subby'], + 'reese': ['reese', 'reese_bass'], + 'acid': ['acid', '303', 'tb303'], + # Synth - por tipo + 'analog': ['analog', 'analogue', 'moog', 'oberheim'], + 'digital': ['digital', 'fm', 'wavetable', 'serum'], + 'vocal': ['vocal', 'voice', 'vox'], +} + +# Umbrales para clasificación one-shot vs loop +ONESHOT_MAX_DURATION = 2.0 # segundos +LOOP_MIN_DURATION = 1.0 # segundos + +# Preferencia one-shot vs loop por rol +# True = prefiere one-shot, False = prefiere loop, None = sin preferencia +ROLE_ONE_SHOT_PREFERENCE = { + 'kick': True, # Debe ser one-shot + 'clap': True, # Debe ser one-shot + 'hat': True, # Debe ser one-shot + 'hat_closed': True, + 'hat_open': True, + 'snare': True, + 'bass_loop': False, # Debe ser loop + 'vocal_loop': False, # Debe ser loop + 'perc_loop': False, + 'top_loop': False, + 'synth_loop': False, +} + +# Patrones de rechazo duro para roles críticos +# Estos son ERRORES semanticos que nunca deberían pasar +# Expandidos para endurecimiento del sistema (Problema #4) +HARD_REJECT_PATTERNS = { + 'kick': { + 'exclude_keywords': [ + 'roll', 'fill', 'loop', 'hat', 'snare', 'clap', 'vocal', 'synth', 'pad', + 'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song', + 'master', 'top loop', 'top_loop', 'drum loop', 'drum_loop', 'perc loop', + 'melodic', 'chord', 'stab', 'fx', 'riser', 'downlifter', 'atmos', + 'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix' + ], + 'exclude_subcategories': ['snare', 'hat', 'clap', 'perc', 'fx', 'vocal', 'synth'], + 'max_duration': 2.0, # Stricter: kicks longer than 2s are loops + 'must_contain_none': ['full', 'mix', 'demo', 'song', 'master'], + 'must_contain_one': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'], + }, + 'clap': { + 'exclude_keywords': [ + 'roll', 'fill', 'loop', 'hat', 'kick', 'vocal', 'bass', + 'full drum', 'full mix', 'demo', 'song', 'master', 'top', 'perc loop', + 'snare roll', 'snare_roll', 'snareroll', 'complete', 'mixed', 'stems' + ], + 'exclude_subcategories': ['kick', 'hat', 'fx', 'vocal', 'bass'], + 'must_contain_one': ['clap', 'hand', 'handclap'], + 'max_duration': 2.0, + 'must_contain_none': ['full', 'mix', 'snare roll', 'snare_roll'], + }, + 'hat': { + 'exclude_keywords': [ + 'roll', 'kick', 'snare', 'clap', 'vocal', 'bass', 'synth', 'pad', + 'full drum', 'full mix', 'demo', 'song', 'master', 'bass loop', + 'top loop', 'drum loop', 'perc loop', 'full_mix', 'fulldrum', + 'complete', 'mixed', 'stems', 'kick drum', 'snare drum' + ], + 'exclude_subcategories': ['kick', 'snare', 'clap', 'bass', 'vocal'], + 'max_duration': 1.5, + 'must_contain_none': ['full', 'mix', 'demo', 'complete'], + 'must_contain_one': ['hat', 'hh', 'hihat', 'hi-hat', 'cymbal', 'open hat', 'closed hat'], + }, + 'bass_loop': { + 'exclude_keywords': [ + 'drum', 'hat', 'kick', 'snare', 'clap', 'perc', 'top loop', 'top_loop', + 'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song', + 'master', 'vocal', 'vocal loop', 'vocal_loop', 'fx', 'atmos', 'pad', + 'drum loop', 'drum_loop', 'perc loop', 'melodic', 'chord', 'synth loop', + 'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix' + ], + 'exclude_subcategories': ['drum', 'perc', 'fx', 'vocal', 'hat'], + 'min_duration': 2.0, + 'must_contain_one': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'], + 'must_contain_none': ['full', 'mix', 'drum', 'top', 'vocal'], + }, + 'vocal_loop': { + 'exclude_keywords': [ + 'drum', 'hat', 'kick', 'snare', 'bass', 'synth', 'pad', 'fx', + 'full drum', 'full mix', 'demo', 'song', 'master', 'one shot', 'oneshot', + 'shot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop', + 'complete', 'mixed', 'stems', 'bounce', 'preview', 'loop kit' + ], + 'exclude_subcategories': ['drum', 'bass', 'perc', 'fx', 'hat'], + 'min_duration': 2.0, # Must be at least 2s to be a loop + 'must_contain_one': ['vocal', 'vox', 'voice', 'sing', 'chorus', 'verse', 'chant', 'acapella'], + 'must_contain_none': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot'], + }, + 'top_loop': { + 'exclude_keywords': [ + 'bass', 'bass loop', 'vocal', 'vocal loop', 'synth loop', 'pad', + 'demo', 'song', 'master', 'fx', 'atmos', 'riser', 'downlifter', + 'melodic', 'chord', 'stab', 'complete', 'mixed', 'stems', 'snare roll' + ], + 'exclude_subcategories': ['bass', 'vocal', 'fx', 'pad', 'synth'], + 'must_contain_one': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'conga', 'bongo', 'full drum'], + 'min_duration': 1.5, + 'must_contain_none': ['bass', 'vocal', 'synth loop'], + }, + 'fill_fx': { + 'exclude_keywords': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad', + 'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop', + 'complete', 'mixed', 'stems', 'bass loop', 'vocal loop' + ], + 'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass', 'vocal'], + 'must_contain_one': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition', 'downlifter'], + 'max_duration': 4.0, + }, + 'snare_roll': { + 'exclude_keywords': [ + 'kick', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad', + 'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'atmos', + 'complete', 'mixed', 'stems', 'one shot', 'drum loop', 'bass loop' + ], + 'exclude_subcategories': ['kick', 'hat', 'clap', 'bass', 'vocal', 'fx'], + 'must_contain_one': ['snare', 'roll', 'fill', 'snareroll', 'buildup', 'build up'], + 'max_duration': 4.0, + }, + 'atmos_fx': { + 'exclude_keywords': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal loop', + 'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop', + 'complete', 'mixed', 'stems', 'snare roll', 'fill', 'perc loop' + ], + 'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass'], + 'must_contain_one': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'fx', 'riser', 'noise', 'ambient'], + 'min_duration': 2.0, + }, + 'crash_fx': { + 'must_contain_one': ['crash', 'impact', 'cymbal', 'ride', 'uplifter', 'downlifter'], + 'exclude_keywords': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'drum loop', 'complete kit'], + 'max_duration': 3.0, + }, + 'synth_loop': { + 'exclude_keywords': [ + 'drum', 'kick', 'snare', 'hat', 'vocal', 'bass loop', 'full mix', + 'demo', 'song', 'master', 'complete', 'mixed', 'stems', 'drum loop', + 'perc loop', 'top loop', 'one shot' + ], + 'must_contain_one': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody', 'hook', 'sequence'], + 'min_duration': 1.5, + }, +} + +# Keywords sospechosos que penalizan (pero no rechazan) el score +# Penalización soft del 30% por cada keyword encontrado +SUSPICIOUS_KEYWORDS = { + 'kick': ['full', 'mix', 'demo', 'song', 'master', 'complete', 'stereo', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'kit', 'pack'], + 'clap': ['full', 'mix', 'demo', 'song', 'snare roll', 'snare_roll', 'fill', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'loop', 'groove', 'top loop'], + 'hat': ['full', 'mix', 'demo', 'song', 'loop', 'complete', 'stems', 'full kit', + 'bounce', 'preview', 'final', 'mixed', 'kick', 'snare', 'bass'], + 'bass_loop': ['full', 'mix', 'demo', 'vocal', 'top', 'drum loop', 'full drum', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'perc', 'snare', 'hat', 'kick'], + 'vocal_loop': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot', 'drum', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'bass loop', 'loop kit'], + 'top_loop': ['bass', 'vocal', 'synth loop', 'demo', 'stems', 'snare roll', + 'bounce', 'preview', 'final', 'mixed', 'percussion', 'hat loop'], + 'fill_fx': ['loop', 'groove', 'kick', 'snare', 'bass', 'vocal', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'drum loop'], + 'snare_roll': ['loop', 'groove', 'kick', 'hat', 'bass', 'vocal', 'atmos', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'clap'], + 'atmos_fx': ['kick', 'snare', 'hat', 'clap', 'bass', 'loop', 'groove', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'drum loop', 'vocal loop'], + 'synth_loop': ['drum', 'vocal', 'bass loop', 'full mix', 'demo', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'one shot', 'hit'], + 'crash_fx': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'hat loop', 'top loop'], + 'perc_loop': ['bass', 'vocal', 'synth', 'demo', 'full mix', 'stems', + 'bounce', 'preview', 'final', 'mixed', 'snare roll'], +} + +# Keywords requeridos por rol - Validación positiva +ROLE_REQUIRED_KEYWORDS = { + 'kick': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'], + 'snare': ['snare', 'snr', 'sd', 'rim', 'rimshot'], + 'clap': ['clap', 'clp', 'handclap', 'hand clap'], + 'hat': ['hat', 'hh', 'hihat', 'hi hat', 'hi-hat', 'closed hat', 'open hat', 'cymbal'], + 'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'], + 'vocal_loop': ['vocal', 'vox', 'voice', 'acapella', 'chant', 'sing'], + 'top_loop': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'full drum'], + 'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody'], + 'crash_fx': ['crash', 'cymbal', 'impact', 'ride', 'uplifter'], + 'fill_fx': ['fill', 'transition', 'tom', 'break', 'riser'], + 'snare_roll': ['roll', 'snare', 'build', 'buildup', 'snareroll'], + 'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise', 'sweep'], + 'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab', 'importante'], + 'perc_loop': ['perc', 'percussion', 'shaker', 'conga', 'bongo'], +} + +# ============================================================================ +# SISTEMA DE EXCLUSIONES POR ROL - Problema #4 +# Define qué samples NO son apropiados para cada rol +# ============================================================================ +ROLE_EXCLUSION_PATTERNS = { + 'kick': { + 'exclude_keywords': [ + 'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix', 'demo', 'song', + 'master', 'top loop', 'drum loop', 'snare roll', 'fill', 'hat loop', + 'vocal loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview', + 'snare', 'clap', 'hat', 'bass loop', 'vocal', 'synth', 'pad', 'atmos' + ], + 'max_duration': 2.5, # Reject if longer than 2.5s + 'min_required_keywords': ['kick', 'bd', 'bass_drum', '808', 'kickdrum'], + }, + 'clap': { + 'exclude_keywords': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master', + 'snare roll', 'snare_roll', 'hat loop', 'kick loop', 'top loop', 'drum loop', + 'bass loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview', + 'kick', 'hat', 'vocal', 'bass', 'synth', 'pad' + ], + 'max_duration': 2.0, + 'min_required_keywords': ['clap', 'hand', 'handclap'], + }, + 'hat': { + 'exclude_keywords': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master', + 'kick loop', 'snare loop', 'bass loop', 'vocal loop', 'complete', 'full kit', + 'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'clap', 'bass' + ], + 'max_duration': 2.0, + 'min_required_keywords': ['hat', 'hh', 'hihat', 'cymbal', 'open hat', 'closed hat'], + }, + 'bass_loop': { + 'exclude_keywords': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master', + 'top loop', 'vocal loop', 'vocal_loop', 'drum loop', 'hat loop', 'snare loop', + 'perc loop', 'fx loop', 'atmos', 'complete', 'mixed', 'stems', 'bounce', 'preview', + 'kick', 'snare', 'hat', 'vocal' + ], + 'min_duration': 1.5, + 'min_required_keywords': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'], + }, + 'vocal_loop': { + 'exclude_keywords': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master', + 'one shot', 'oneshot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop', + 'complete', 'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'hat', 'bass' + ], + 'min_duration': 1.5, + 'min_required_keywords': ['vocal', 'vox', 'voice', 'sing', 'chant', 'acapella', 'phrase'], + }, + 'top_loop': { + 'exclude_keywords': [ + 'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop', 'demo', 'song', 'master', + 'synth loop', 'pad', 'atmos', 'riser', 'downlifter', 'complete', 'mixed', + 'stems', 'bounce', 'preview', 'bass', 'vocal', 'synth' + ], + 'min_duration': 1.0, + 'min_required_keywords': ['top', 'perc', 'drum', 'groove', 'hat', 'full drum', 'drum loop'], + }, + 'fill_fx': { + 'exclude_keywords': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song', + 'master', 'loop', 'groove', 'complete', 'mixed', 'stems', 'bounce', 'preview', + 'drum loop', 'bass loop', 'vocal loop' + ], + 'max_duration': 6.0, + 'min_required_keywords': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition'], + }, + 'snare_roll': { + 'exclude_keywords': [ + 'kick', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song', 'master', + 'atmos', 'pad', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'one shot', + 'loop', 'groove' + ], + 'max_duration': 6.0, + 'min_required_keywords': ['roll', 'snare', 'fill', 'buildup', 'build up', 'snareroll'], + }, + 'atmos_fx': { + 'exclude_keywords': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'full mix', 'demo', 'song', 'master', + 'drum loop', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'snare roll', + 'fill', 'perc loop', 'vocal' + ], + 'min_duration': 1.5, + 'min_required_keywords': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'noise', 'ambient'], + }, + 'crash_fx': { + 'exclude_keywords': [ + 'full mix', 'demo', 'song', 'master', 'loop', 'complete', 'mixed', 'stems', + 'bounce', 'preview', 'bass', 'vocal', 'kick', 'snare' + ], + 'max_duration': 4.0, + 'min_required_keywords': ['crash', 'cymbal', 'impact', 'ride', 'uplifter', 'downlifter'], + }, +} + + +def _check_role_exclusion(sample_name: str, role: str) -> Tuple[bool, str]: + """ + Verifica si un sample debe ser excluido para un rol específico. + + Returns: + (excluded, reason) - True si debe ser excluido, False si pasa + """ + role_lower = role.lower() + if role_lower not in ROLE_EXCLUSION_PATTERNS: + return False, "" + + patterns = ROLE_EXCLUSION_PATTERNS[role_lower] + name_lower = sample_name.lower() + + # Check excluded keywords + for keyword in patterns.get('exclude_keywords', []): + if keyword in name_lower: + return True, f"excluded keyword '{keyword}'" + + # Check required keywords + required = patterns.get('min_required_keywords', []) + if required: + found = any(kw in name_lower for kw in required) + if not found: + return True, f"missing required keyword (need one of: {required})" + + return False, "" + +ROLE_DURATION_RANGES = { + 'kick': (0.05, 2.5), + 'snare': (0.05, 3.0), + 'clap': (0.05, 2.0), + 'hat': (0.05, 2.0), + 'bass_loop': (1.5, 32.0), + 'vocal_loop': (1.0, 32.0), + 'top_loop': (0.75, 32.0), + 'synth_loop': (0.75, 32.0), + 'crash_fx': (0.3, 8.0), + 'fill_fx': (0.3, 12.0), + 'snare_roll': (0.5, 12.0), + 'atmos_fx': (0.5, 32.0), + 'vocal_shot': (0.1, 4.0), + 'perc_loop': (0.75, 32.0), +} + + +def _extract_sample_family(sample_name: str) -> str: + """Extrae la familia de un sample basado en su nombre.""" + name_lower = sample_name.lower() + for family, keywords in SAMPLE_FAMILIES.items(): + for kw in keywords: + if kw in name_lower: + return family + return 'unknown' + + +def _is_oneshot(sample: 'Sample') -> bool: + """Determina si un sample es one-shot basado en duración y nombre.""" + name_lower = sample.name.lower() + duration = sample.duration or 0 + + # Indicadores de one-shot en el nombre + oneshot_keywords = ['one shot', 'oneshot', 'hit', 'single', 'stab'] + if any(kw in name_lower for kw in oneshot_keywords): + return True + + # Indicadores de loop en el nombre + loop_keywords = ['loop', 'groove', 'pattern', 'sequence'] + if any(kw in name_lower for kw in loop_keywords): + return False + + # Por duración + if duration > 0: + return duration < ONESHOT_MAX_DURATION + + # Default: asumir one-shot para drums + return sample.category == 'drums' + + +# ============================================================================ +# MAPEO MIDI +# ============================================================================ + +# Mapeo de notas MIDI para diferentes tipos de samples +MIDI_NOTE_MAPPING = { + # Drums (General MIDI) + 'kick': 36, # C1 + 'kick_deep': 35, # B0 + 'snare': 38, # D1 + 'snare_rim': 37, # C#1 + 'clap': 39, # D#1 / también 50 (D2) + 'hat_closed': 42, # F#1 + 'hat_open': 46, # A#1 + 'hat_pedal': 44, # G#1 + 'tom_low': 41, # F1 + 'tom_mid': 47, # B1 + 'tom_high': 50, # D2 + 'crash': 49, # C#2 + 'ride': 51, # D#2 + 'ride_bell': 53, # F2 + 'perc_low': 43, # G1 + 'perc_mid': 45, # A1 + 'perc_high': 48, # C2 + 'shaker': 54, # F#2 + 'tambourine': 54, # F#2 + 'cowbell': 56, # G#2 + + # Instrumentos melódicos (rango usable) + 'bass': list(range(36, 48)), # C1-B1 + 'lead': list(range(60, 84)), # C4-B6 + 'pad': list(range(48, 72)), # C2-B4 + 'pluck': list(range(60, 84)), # C4-B6 + 'arp': list(range(60, 84)), # C4-B6 + 'chord': list(range(48, 72)), # C2-B4 + 'vocal': list(range(60, 84)), # C4-B6 +} + + +@dataclass +class DrumKit: + """Kit de batería completo""" + name: str + kick: Optional[Sample] = None + snare: Optional[Sample] = None + clap: Optional[Sample] = None + hat_closed: Optional[Sample] = None + hat_open: Optional[Sample] = None + perc1: Optional[Sample] = None + perc2: Optional[Sample] = None + tom: Optional[Sample] = None + crash: Optional[Sample] = None + + def to_dict(self) -> Dict[str, Any]: + """Convierte el kit a diccionario""" + return { + 'name': self.name, + 'kick': self.kick.to_dict() if self.kick else None, + 'snare': self.snare.to_dict() if self.snare else None, + 'clap': self.clap.to_dict() if self.clap else None, + 'hat_closed': self.hat_closed.to_dict() if self.hat_closed else None, + 'hat_open': self.hat_open.to_dict() if self.hat_open else None, + 'perc1': self.perc1.to_dict() if self.perc1 else None, + 'perc2': self.perc2.to_dict() if self.perc2 else None, + 'tom': self.tom.to_dict() if self.tom else None, + 'crash': self.crash.to_dict() if self.crash else None, + } + + def get_midi_mapping(self) -> Dict[int, Optional[Sample]]: + """Retorna mapeo de notas MIDI a samples""" + mapping = {} + if self.kick: + mapping[MIDI_NOTE_MAPPING['kick']] = self.kick + if self.snare: + mapping[MIDI_NOTE_MAPPING['snare']] = self.snare + if self.clap: + mapping[MIDI_NOTE_MAPPING['clap']] = self.clap + if self.hat_closed: + mapping[MIDI_NOTE_MAPPING['hat_closed']] = self.hat_closed + if self.hat_open: + mapping[MIDI_NOTE_MAPPING['hat_open']] = self.hat_open + if self.tom: + mapping[MIDI_NOTE_MAPPING['tom_mid']] = self.tom + if self.crash: + mapping[MIDI_NOTE_MAPPING['crash']] = self.crash + return mapping + + +@dataclass +class InstrumentGroup: + """Grupo de instrumentos para un estilo""" + genre: str + key: str + bpm: float + drums: DrumKit = field(default_factory=lambda: DrumKit(name="default")) + bass: List[Sample] = field(default_factory=list) + synths: List[Sample] = field(default_factory=list) + fx: List[Sample] = field(default_factory=list) + vocals: List[Sample] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + 'genre': self.genre, + 'key': self.key, + 'bpm': self.bpm, + 'drums': self.drums.to_dict(), + 'bass': [s.to_dict() for s in self.bass], + 'synths': [s.to_dict() for s in self.synths], + 'fx': [s.to_dict() for s in self.fx], + 'vocals': [s.to_dict() for s in self.vocals], + } + + +class SampleSelector: + """ + Selector inteligente de samples (Fase 4 mejorada). + + Proporciona selección contextual basada en: + - Género musical + - Tonalidad (key) y compatibilidad armónica + - BPM y tempo + - Estilo y características + + Mejoras Fase 4: + - Ranking multi-factor con scoring vectorizado + - Seeding determinista para reproducibilidad + - Validación de roles para evitar elecciones absurdas + - Penalización de familias repetidas + - Balance one-shots vs loops + """ + + def __init__(self, manager: Optional[SampleManager] = None, session_seed: Optional[int] = None): + """ + Inicializa el selector. + + Args: + manager: Instancia de SampleManager (usa global si None) + session_seed: Semilla para reproducibilidad dentro de una sesión + """ + if manager is None and MANAGER_AVAILABLE: + manager = get_manager() + + self.manager = manager + self.analyzer = AudioAnalyzer() if MANAGER_AVAILABLE else None + + # Historial de samples usados (ID -> timestamp) + self._recent_sample_ids = deque(maxlen=100) + # Historial de familias usadas (family -> count) + self._recent_families = defaultdict(int) + # Historial de roles usados (role -> [sample_ids]) + self._role_history = defaultdict(list) + + # Tracking de cooldown para familias + self._family_last_used: Dict[str, int] = {} # family -> selection_index + self._selection_counter: int = 0 # Increment each time a sample is selected + + # Semilla de sesión para diversidad controlada + self._session_seed = session_seed or int(time.time() * 1000) % (2**31) + + # Preferencias de balance one-shot vs loop + self._oneshot_preference = 0.7 # 70% preferencia one-shots para drums + self._loop_preference = 0.6 # 60% preferencia loops para synths + + # Configuración de GPU + self._use_gpu = GPU_AVAILABLE + if self._use_gpu: + logger.info("GPU disponible, usando aceleración para cálculos vectorizados") + + # Decision logging + self._decision_log: list[SampleDecision] = [] + self._log_decisions: bool = False # Por defecto False para no impactar performance + + def _generate_selection_seed(self, context: str = "") -> int: + """ + Genera una semilla determinista para cada selección. + Combina session_seed, contador y contexto. + """ + self._selection_counter += 1 + seed_data = f"{self._session_seed}_{self._selection_counter}_{context}" + return int(hashlib.md5(seed_data.encode()).hexdigest()[:8], 16) + + def _calculate_sample_score(self, + sample: 'Sample', + target_key: Optional[str] = None, + target_bpm: Optional[float] = None, + target_role: Optional[str] = None, + target_genre: Optional[str] = None, + prefer_oneshot: Optional[bool] = None) -> float: + """ + Calcula un score completo para un sample basado en múltiples factores. + + Factores: + - Rating del sample (peso: 0.15) + - Compatibilidad de key (peso: 0.20) + - Compatibilidad de BPM (peso: 0.15) + - Ajuste de género (peso: 0.10) + - Validación de rol (peso: 0.15) + - Penalización por repetición (peso: 0.10) + - Balance one-shot/loop (peso: 0.10) + - Energía y características (peso: 0.05) + + Returns: + Score normalizado entre 0 y 1 + """ + score = 0.0 + weights = 0.0 + + # 1. Rating del sample (0-5 -> 0-1) + rating_score = min(1.0, (sample.rating or 0) / 5.0) + score += rating_score * 0.15 + weights += 0.15 + + # 2. Compatibilidad de key + if target_key and sample.key: + if MANAGER_AVAILABLE: + key_compat = calculate_key_compatibility(target_key, sample.key) + else: + key_compat = 1.0 if sample.key == target_key else 0.5 + score += key_compat * 0.20 + weights += 0.20 + elif target_key: + # Sin key info, score neutral + score += 0.5 * 0.20 + weights += 0.20 + + # 3. Compatibilidad de BPM + if target_bpm and sample.bpm: + bpm_diff = abs(sample.bpm - target_bpm) + if bpm_diff == 0: + bpm_score = 1.0 + elif bpm_diff <= 3: + bpm_score = 0.95 + elif bpm_diff <= 6: + bpm_score = 0.85 + elif bpm_diff <= 10: + bpm_score = 0.70 + else: + bpm_score = max(0.2, 1.0 - (bpm_diff / 30)) + score += bpm_score * 0.15 + weights += 0.15 + elif target_bpm: + score += 0.5 * 0.15 + weights += 0.15 + + # 4. Ajuste de género + if target_genre and sample.genres: + genre_lower = target_genre.lower().replace(' ', '-') + sample_genres_lower = [g.lower().replace(' ', '-') for g in sample.genres] + if genre_lower in sample_genres_lower: + genre_score = 1.0 + elif any(g in genre_lower or genre_lower in g for g in sample_genres_lower): + genre_score = 0.7 + else: + genre_score = 0.3 + score += genre_score * 0.10 + weights += 0.10 + + # 5. Validación de rol (EVITA ELECCIONES ABSURDAS) + if target_role: + role_score = self._validate_sample_for_role(sample, target_role) + score += role_score * 0.15 + weights += 0.15 + + # 6. Penalización por repetición reciente + repetition_penalty = self._calculate_repetition_penalty(sample) + score += repetition_penalty * 0.10 + weights += 0.10 + + # 7. Balance one-shot vs loop + if prefer_oneshot is not None: + is_oneshot = _is_oneshot(sample) + if prefer_oneshot and is_oneshot: + balance_score = 0.9 + elif not prefer_oneshot and not is_oneshot: + balance_score = 0.9 + else: + balance_score = 0.5 + score += balance_score * 0.10 + weights += 0.10 + + # Bonus por tipo correcto (one-shot vs loop) para roles críticos + if target_role and target_role.lower() in ROLE_ONE_SHOT_PREFERENCE: + prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role.lower()] + is_oneshot = _is_oneshot(sample) + if prefers_oneshot == is_oneshot: + score *= 1.2 # 20% bonus por tipo correcto + weights += 0.1 + + # 8. Energía y características espectrales + if sample.rms_energy > 0: + # Preferir samples con buena energía (no muy bajos ni saturados) + energy_score = min(1.0, sample.rms_energy * 2) + score += energy_score * 0.05 + weights += 0.05 + + # 9. Cooldown por familia (penaliza familias recientemente usadas) + if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop']: + family = _extract_sample_family(sample.name) + cooldown_penalty = self._get_family_cooldown_penalty(family) + score *= cooldown_penalty + weights += 0.15 # Peso significativo para cooldown + if cooldown_penalty < 0.5: + logger.debug("COOLDOWN: family '%s' has cooldown penalty %.2f (used %d selections ago)", + family, cooldown_penalty, self._selection_counter - self._family_last_used.get(family, 0)) + + # 10. Cross-generation penalty para roles críticos + if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop', 'snare']: + family = _extract_sample_family(sample.name) + sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or '' + cross_penalty = self._get_cross_generation_penalty(family, sample_path, target_role.lower()) + if cross_penalty < 1.0: + score *= cross_penalty + weights += 0.12 + logger.debug("CROSS_GEN: family '%s' has cross-gen penalty %.2f for role '%s' (used in %d prev generations)", + family, cross_penalty, target_role.lower(), _cross_generation_family_memory.get(family, 0)) + + # Normalizar + return score / weights if weights > 0 else 0.5 + + def _validate_sample_for_role(self, sample: 'Sample', target_role: str) -> float: + """ + Valida si un sample es apropiado para un rol específico. + Retorna un score de 0 a 1, donde 0 significa "completamente inapropiado". + + Esto EVITA ELECCIONES ABSURDAS como: + - Snare roll donde va clap + - Hi-hat donde va kick + - Vocal sample en drum kit + """ + target_role_lower = target_role.lower() + sample_name_lower = sample.name.lower() + sample_type_lower = (sample.sample_type or '').lower() + sample_subcat_lower = (sample.subcategory or '').lower() + sample_duration = getattr(sample, 'duration', None) or 0 + + # Check using old DRUM_ROLE_VALID_TYPES (legacy support) + valid_types = DRUM_ROLE_VALID_TYPES.get(target_role_lower, []) + for vtype in valid_types: + if vtype in sample_type_lower or sample_type_lower in vtype: + return 1.0 + if vtype in sample_subcat_lower or sample_subcat_lower in vtype: + return 0.95 + + for vtype in valid_types: + if vtype in sample_name_lower: + return 0.9 + + # Check using ROLE_REQUIRED_KEYWORDS for required keywords validation + required_keywords = ROLE_REQUIRED_KEYWORDS.get(target_role_lower, []) + if required_keywords: + for kw in required_keywords: + if kw in sample_name_lower: + return 0.85 + if kw in sample_type_lower: + return 0.80 + + duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0)) + if sample_duration > 0 and duration_max < 999.0: + if duration_min <= sample_duration <= duration_max: + pass + elif sample_duration < duration_min: + return 0.25 + elif sample_duration > duration_max: + return 0.20 + + if sample.category == 'drums': + return 0.30 + + exclusive_roles = { + 'kick': ['vocal', 'bass', 'synth', 'pad', 'fx'], + 'snare': ['vocal', 'bass', 'synth'], + 'clap': ['vocal', 'bass', 'kick'], + 'hat_closed': ['vocal', 'bass', 'kick'], + 'hat_open': ['vocal', 'bass', 'kick'], + 'bass_loop': ['drum', 'vocal', 'fx'], + 'vocal_loop': ['drum', 'bass', 'kick'], + } + + excluded = exclusive_roles.get(target_role_lower, []) + for excluded_type in excluded: + if excluded_type in sample_name_lower: + return 0.0 + + return 0.15 + + def _hard_reject_check(self, sample: 'Sample', target_role: str) -> tuple[bool, str]: + """ + Verifica rechazo duro para roles críticos. + + Retorna (should_reject, reason) donde: + - should_reject: True si el sample debe ser rechazado completamente + - reason: string explicando por qué + + Esto es más estricto que _validate_sample_for_role() y captura + casos que son claramente errores semánticos. + + Mejorado para Problema #4: + - Integra ROLE_EXCLUSION_PATTERNS + - Logging detallado de rechazos + """ + target_role_lower = target_role.lower() + sample_name_lower = sample.name.lower() + sample_duration = getattr(sample, 'duration', None) + + # 1. Check ROLE_EXCLUSION_PATTERNS (nuevo sistema endurecido) + excluded, exclusion_reason = _check_role_exclusion(sample.name, target_role) + if excluded: + logger.debug("HARD_REJECT (exclusion): %s for role '%s': %s", + sample.name, target_role, exclusion_reason) + return True, f"ROLE_EXCLUSION: {exclusion_reason}" + + # 2. Check HARD_REJECT_PATTERNS (sistema existente) + if target_role_lower not in HARD_REJECT_PATTERNS: + # Fallback a rangos de duración si no hay patrones específicos + duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0)) + if sample_duration and duration_max < 999.0: + if sample_duration < duration_min: + return True, f"duration {sample_duration:.1f}s below min {duration_min}s for {target_role}" + if sample_duration > duration_max: + return True, f"duration {sample_duration:.1f}s exceeds max {duration_max}s for {target_role}" + return False, "" + + patterns = HARD_REJECT_PATTERNS[target_role_lower] + sample_type_lower = (sample.sample_type or '').lower() + sample_subcat_lower = (sample.subcategory or '').lower() + + # Check excluded keywords + for kw in patterns.get('exclude_keywords', []): + if kw in sample_name_lower: + logger.debug("HARD_REJECT (keyword): %s for role '%s': contains '%s'", + sample.name, target_role, kw) + return True, f"contains excluded keyword '{kw}'" + + # Check excluded subcategories + for subcat in patterns.get('exclude_subcategories', []): + if subcat in sample_subcat_lower or subcat in sample_type_lower: + logger.debug("HARD_REJECT (subcat): %s for role '%s': subcategory '%s'", + sample.name, target_role, subcat) + return True, f"has excluded subcategory '{subcat}'" + + # Check duration constraints + max_duration = patterns.get('max_duration') + min_duration = patterns.get('min_duration') + if sample_duration: + if max_duration and sample_duration > max_duration: + logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs > max %.1fs", + sample.name, target_role, sample_duration, max_duration) + return True, f"duration {sample_duration:.1f}s exceeds max {max_duration}s" + if min_duration and sample_duration < min_duration: + logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs < min %.1fs", + sample.name, target_role, sample_duration, min_duration) + return True, f"duration {sample_duration:.1f}s below min {min_duration}s" + + # Check must_contain requirements + must_contain = patterns.get('must_contain_one', []) + if must_contain: + found = any(kw in sample_name_lower or kw in sample_type_lower for kw in must_contain) + if not found: + logger.debug("HARD_REJECT (missing): %s for role '%s': needs one of %s", + sample.name, target_role, must_contain) + return True, f"does not contain any of: {must_contain}" + + # Check must_contain_none keywords + for kw in patterns.get('must_contain_none', []): + if kw in sample_name_lower: + logger.debug("HARD_REJECT (forbidden): %s for role '%s': contains '%s'", + sample.name, target_role, kw) + return True, f"contains excluded keyword '{kw}'" + + return False, "" + + + def _validate_loop_preference(self, sample: 'Sample', target_role: str) -> tuple[bool, str]: + """ + Valida preferencia de one-shot vs loop para roles críticos. + + Retorna (is_valid, reason) donde: + - is_valid: True si el sample cumple la preferencia + - reason: string explicando violación si aplica + """ + target_role_lower = target_role.lower() + + if target_role_lower not in ROLE_ONE_SHOT_PREFERENCE: + return True, "" # No hay preferencia definida + + prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role_lower] + is_oneshot = _is_oneshot(sample) + + if prefers_oneshot and not is_oneshot: + return False, f"role requires one-shot but sample is loop (duration={sample.duration:.1f}s)" + elif not prefers_oneshot and is_oneshot: + return False, f"role requires loop but sample is one-shot (duration={sample.duration:.1f}s)" + + return True, "" + + def _calculate_repetition_penalty(self, sample: 'Sample') -> float: + """ + Calcula penalización por repetición de sample y familia. + Retorna 1.0 (sin penalización) a 0.1 (penalización máxima). + """ + penalty = 1.0 + + # Penalizar sample ya usado + if getattr(sample, "id", None) in self._recent_sample_ids: + penalty *= 0.3 + + # Penalizar familia repetida + family = _extract_sample_family(sample.name) + family_count = self._recent_families.get(family, 0) + if family_count > 0: + # Penalización decreciente: 0.85, 0.70, 0.55, ... + penalty *= max(0.3, 1.0 - (family_count * 0.15)) + + return penalty + + def _remember_sample(self, sample: Optional['Sample'], role: str = None) -> None: + """Registra un sample como usado para evitar repeticiones. + + Ahora integra con diversity_memory.py para persistencia cross-generation. + """ + if sample is not None and getattr(sample, "id", None): + self._recent_sample_ids.append(sample.id) + family = _extract_sample_family(sample.name) + self._recent_families[family] += 1 + + # Track para esta generación específica + if hasattr(self, '_generation_families'): + self._generation_families[family] += 1 + + # Track path para cross-generation memory + if hasattr(self, '_generation_paths'): + sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or '' + if sample_path: + self._generation_paths[sample_path] += 1 + + # Track para cooldown (dentro de generación) + self._selection_counter += 1 + self._family_last_used[family] = self._selection_counter + + # Add to recent sample diversity memory + if role: + sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or '' + if sample_path: + add_to_recent_memory(role, sample_path) + + # REGISTRAR EN MEMORIA PERSISTENTE (diversity_memory.py) + # Solo para roles críticos para evitar overhead excesivo + if role and DIVERSITY_MEMORY_AVAILABLE: + try: + sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or '' + if sample_path: + record_sample_usage(role, sample_path, sample.name) + except Exception as e: + logger.debug("Error registrando sample en memoria persistente: %s", e) + + def _get_family_cooldown_penalty(self, family: str) -> float: + """ + Calcula penalización por cooldown de familia. + + Retorna 1.0 (sin penalización) a 0.0 (penalización máxima - rechazo duro). + + Las familias recientemente usadas tienen penalización progresiva: + - Usado hace 0 selecciones: 0.0 (rechazo duro - no reusable inmediatamente) + - Usado hace 1 selección: 0.20 + - Usado hace 2 selecciones: 0.40 + - Usado hace 3 selecciones: 0.55 + - Usado hace 4 selecciones: 0.70 + - Usado hace 5 selecciones: 0.85 + - Usado hace COOLDOWN_WINDOW o más: 1.0 (sin penalización) + """ + if family not in self._family_last_used: + return 1.0 + + selections_ago = self._selection_counter - self._family_last_used[family] + + if selections_ago <= 0: + return 0.0 + elif selections_ago == 1: + return 0.20 + elif selections_ago == 2: + return 0.40 + elif selections_ago == 3: + return 0.55 + elif selections_ago == 4: + return 0.70 + elif selections_ago == 5: + return 0.85 + elif selections_ago >= COOLDOWN_WINDOW: + return 1.0 + else: + return min(1.0, 0.20 + (selections_ago / COOLDOWN_WINDOW) * 0.80) + + def _get_cross_generation_penalty(self, family: str, path: str = None, role: str = None) -> float: + """ + Calcula penalización por uso en generaciones anteriores. + + Retorna factor de penalty (0.0 - 1.0) basado en uso reciente. + + Ahora integra con diversity_memory.py para penalización persistente + de familias para roles críticos. + """ + # PRIMERO: Usar sistema persistente si está disponible y es rol crítico + if role and DIVERSITY_MEMORY_AVAILABLE: + try: + persistent_penalty = get_penalty_for_sample(role, path or '', '') + if persistent_penalty < 1.0: + logger.debug("CROSS_GEN (persistent): family penalty for role '%s': %.2f", + role, persistent_penalty) + return persistent_penalty + except Exception as e: + logger.debug("Error obteniendo penalización persistente: %s", e) + + # FALLBACK: Usar memoria en RAM (legacy) + family_penalty = 1.0 + cross_gen_count = _cross_generation_family_memory.get(family, 0) + if cross_gen_count >= 4: + family_penalty = 0.08 + elif cross_gen_count >= 3: + family_penalty = 0.20 + elif cross_gen_count >= 2: + family_penalty = 0.40 + elif cross_gen_count >= 1: + family_penalty = 0.70 + + path_penalty = 1.0 + if path and path in _cross_generation_path_memory: + path_count = _cross_generation_path_memory.get(path, 0) + if path_count >= 3: + path_penalty = 0.05 + elif path_count >= 2: + path_penalty = 0.15 + else: + path_penalty = 0.35 + + recent_role_penalty = 1.0 + if role and path: + recent_role_penalty = get_recent_memory_penalty(role, path) + + return family_penalty * path_penalty * recent_role_penalty + + def _apply_suspicion_penalty(self, score: float, sample_name: str, role: str) -> float: + """ + Aplica penalty a samples con nombres sospechosos para el rol. + + A diferencia de HARD_REJECT_PATTERNS, esto es un penalty suave + que reduce el score pero no elimina completamente el candidato. + + Args: + score: Score base del sample + sample_name: Nombre del sample + role: Rol objetivo + + Returns: + Score ajustado con penalty aplicado + """ + role_lower = role.lower() if role else "" + if role_lower not in SUSPICIOUS_KEYWORDS: + return score + + name_lower = sample_name.lower() + suspicious = SUSPICIOUS_KEYWORDS[role_lower] + + penalty = 1.0 + for kw in suspicious: + if kw in name_lower: + penalty *= 0.7 # 30% penalty per suspicious keyword found + + return score * penalty + + def _break_tie_randomized(self, candidates: List[Dict], seed_base: str = "") -> List[Dict]: + """ + Rompe empates con jitter determinista basado en hash. + + Cuando los scores son muy cercanos (dentro del 5%), usa randomización + determinista para evitar que siempre gane el mismo candidato. + + Args: + candidates: Lista de dicts con 'score' o 'final_score' y 'sample' + seed_base: String base para el seed determinista + + Returns: + Lista reordenada con empates rotos + """ + if len(candidates) <= 1: + return candidates + + # Group by similar scores (within 5%) + result = [] + i = 0 + while i < len(candidates): + # Find all candidates with similar scores + current_score = candidates[i].get('final_score', candidates[i].get('score', 0)) + group = [candidates[i]] + j = i + 1 + while j < len(candidates): + other_score = candidates[j].get('final_score', candidates[j].get('score', 0)) + if abs(current_score - other_score) / max(current_score, other_score, 0.001) < 0.05: + group.append(candidates[j]) + j += 1 + else: + break + + if len(group) > 1: + # Shuffle group deterministically based on names + sample_names = "" + for c in group: + sample = c.get('sample') + if sample: + sample_names += getattr(sample, 'name', '') + seed = int(hashlib.md5((seed_base + sample_names).encode()).hexdigest()[:8], 16) + rng = random.Random(seed) + rng.shuffle(group) + + result.extend(group) + i = j + + return result + + def reset_cooldown_tracking(self) -> None: + """Resetea el tracking de cooldown para nueva generación.""" + self._family_last_used.clear() + self._selection_counter = 0 + self._recent_families.clear() + self._recent_sample_ids.clear() + + def start_generation_tracking(self) -> None: + """Marca el inicio de una nueva generación (llamar al inicio de generate_track).""" + self._generation_families = defaultdict(int) + self._generation_paths: Dict[str, int] = defaultdict(int) + + def end_generation_tracking(self) -> None: + """Marca el fin de una generación y actualiza memoria cross-generation.""" + if hasattr(self, '_generation_families'): + paths_used = list(self._generation_paths.keys()) if hasattr(self, '_generation_paths') else [] + _update_cross_generation_memory(self._generation_families, paths_used) + delattr(self, '_generation_families') + if hasattr(self, '_generation_paths'): + delattr(self, '_generation_paths') + + def _log_decision(self, decision: SampleDecision) -> None: + """Registra una decisión si logging está activado.""" + if self._log_decisions: + self._decision_log.append(decision) + logger.debug("SAMPLE_DECISION: %s", decision.to_log_str()) + + def _pick_ranked_sample(self, + samples: List['Sample'], + target_key: Optional[str] = None, + target_bpm: Optional[float] = None, + target_role: Optional[str] = None, + target_genre: Optional[str] = None, + prefer_oneshot: Optional[bool] = None, + pool_size: int = 12, + context: str = "") -> Optional['Sample']: + """ + Selecciona un sample usando ranking multi-factor con weighted random. + + Args: + samples: Lista de samples candidatos + target_key: Key objetivo para matching armónico + target_bpm: BPM objetivo para matching de tempo + target_role: Rol objetivo para validación (ej: 'kick', 'clap') + target_genre: Género objetivo + prefer_oneshot: Preferencia por one-shot (True) o loop (False) + pool_size: Tamaño del pool de mejores candidatos + context: Contexto para seeding determinista + + Returns: + Sample seleccionado o None si no hay candidatos + """ + if not samples: + return None + + # Calcular scores para todos los samples + scored_samples = [] + for sample in samples: + score = self._calculate_sample_score( + sample, + target_key=target_key, + target_bpm=target_bpm, + target_role=target_role, + target_genre=target_genre, + prefer_oneshot=prefer_oneshot + ) + # Apply suspicion penalty for samples with suspicious names + if target_role: + score = self._apply_suspicion_penalty(score, sample.name, target_role) + scored_samples.append({'score': score, 'sample': sample, 'rejection_reasons': []}) + + # Ordenar por score descendente + scored_samples.sort(key=lambda x: x['score'], reverse=True) + + # Apply tie-breaking with deterministic randomization + scored_samples = self._break_tie_randomized(scored_samples, context) + + # Filtrar por rechazo duro para roles críticos + if target_role: + filtered_samples = [] + for s in scored_samples: + should_reject, reason = self._hard_reject_check(s['sample'], target_role) + if should_reject: + s['rejection_reasons'].append(f"hard_reject: {reason}") + logger.debug("HARD_REJECT: %s for role '%s': %s", s['sample'].name, target_role, reason) + else: + filtered_samples.append(s) + scored_samples = filtered_samples + + if not scored_samples: + logger.warning("All samples hard-rejected for role '%s', using fallback", target_role) + # Validar preferencia one-shot/loop para roles críticos + if target_role: + filtered_samples = [] + for s in scored_samples: + is_valid, reason = self._validate_loop_preference(s['sample'], target_role) + if not is_valid: + s['rejection_reasons'].append(f"loop_pref: {reason}") + logger.debug("LOOP_PREF: rejecting %s for role '%s': %s", s['sample'].name, target_role, reason) + else: + filtered_samples.append(s) + scored_samples = filtered_samples + + if not scored_samples: + logger.warning("All samples rejected by loop preference for role '%s'", target_role) + + + # Tomar top pool_size candidatos + top_samples = scored_samples[:max(1, min(pool_size, len(scored_samples)))] + + # Aplicar jitter con seeding determinista + selection_seed = self._generate_selection_seed(context) + rng = random.Random(selection_seed) + + # Weighted random selection con jitter + weighted: List[Tuple[float, 'Sample']] = [] + for rank, s in enumerate(top_samples): + score = s['score'] + sample = s['sample'] + # Decaimiento por posición en el ranking + rank_weight = max(0.2, 1.0 - (rank * 0.07)) + # Jitter aleatorio + jitter = 0.85 + (rng.random() * 0.30) + final_weight = max(0.01, score * rank_weight * jitter) + weighted.append((final_weight, sample)) + + # Selección por weighted random + if NUMPY_AVAILABLE and len(weighted) > 3: + # Usar numpy para mejor performance + weights = np.array([w for w, _ in weighted]) + weights = weights / weights.sum() + idx = np.random.default_rng(selection_seed).choice(len(weighted), p=weights) + selected = weighted[idx][1] + final_score = weighted[idx][0] + selected_idx = idx + else: + # Fallback a random estándar + total = sum(weight for weight, _ in weighted) + pivot = rng.random() * total + running = 0.0 + selected = weighted[0][1] # default + final_score = weighted[0][0] + selected_idx = 0 + for idx, (weight, sample) in enumerate(weighted): + running += weight + if pivot <= running: + selected = sample + final_score = weight + selected_idx = idx + break + + self._remember_sample(selected, role=target_role) + + # Log decision if enabled + if self._log_decisions and selected: + # Determine bonus factors (would need to be tracked during scoring) + bonus_list = [] + + # Log the selected sample + decision = SampleDecision( + sample_name=selected.name, + target_role=target_role or "unknown", + final_score=final_score, + selected=True, + selection_index=selected_idx, + bonus_factors=bonus_list + ) + self._log_decision(decision) + + # Also log top 5 rejections + for idx, s in enumerate(scored_samples[:5]): # Top 5 rejected + if s['sample'].name != selected.name: + reject_decision = SampleDecision( + sample_name=s['sample'].name, + target_role=target_role or "unknown", + final_score=s['score'], + selected=False, + selection_index=idx, + rejection_reasons=s.get('rejection_reasons', []) + ) + self._log_decision(reject_decision) + + return selected + + def _pick_multiple_ranked(self, + samples: List['Sample'], + count: int, + target_key: Optional[str] = None, + target_bpm: Optional[float] = None, + target_role: Optional[str] = None, + target_genre: Optional[str] = None, + prefer_oneshot: Optional[bool] = None, + pool_size: int = 15, + context: str = "") -> List['Sample']: + """ + Selecciona múltiples samples con diversidad garantizada. + """ + chosen: List['Sample'] = [] + if not samples or count <= 0: + return chosen + + remaining = list(samples) + seen_ids = set() + sub_context = context + + while remaining and len(chosen) < count: + selected = self._pick_ranked_sample( + remaining, + target_key=target_key, + target_bpm=target_bpm, + target_role=target_role, + target_genre=target_genre, + prefer_oneshot=prefer_oneshot, + pool_size=pool_size, + context=f"{sub_context}_{len(chosen)}" + ) + if selected is None: + break + if selected.id not in seen_ids: + chosen.append(selected) + seen_ids.add(selected.id) + remaining = [sample for sample in remaining if sample.id != selected.id] + + return chosen + + def get_decision_log(self) -> list[SampleDecision]: + """Retorna el log de decisiones acumulado.""" + return self._decision_log.copy() + + def clear_decision_log(self) -> None: + """Limpia el log de decisiones.""" + self._decision_log.clear() + + def enable_decision_logging(self, enabled: bool = True) -> None: + """Activa/desactiva logging de decisiones.""" + self._log_decisions = enabled + + def select_for_genre(self, + genre: str, + key: Optional[str] = None, + bpm: Optional[float] = None, + variation: str = "standard", + session_seed: Optional[int] = None) -> InstrumentGroup: + """ + Selecciona un grupo completo de instrumentos para un género. + + Args: + genre: Género musical + key: Tonalidad preferida (auto-selecciona si None) + bpm: BPM preferido (auto-selecciona si None) + variation: Variación del estilo + session_seed: Semilla para reproducibilidad (actualiza si se provee) + + Returns: + InstrumentGroup con samples seleccionados + """ + # Actualizar semilla de sesión si se provee + if session_seed is not None: + self._session_seed = session_seed + self._selection_counter = 0 + + # Normalizar género + genre_profile = self._get_genre_profile(genre) + + # Seleccionar key si no se especificó (con seeding determinista) + if key is None: + rng = random.Random(self._generate_selection_seed("key")) + key = rng.choice(genre_profile.common_keys) + + # Seleccionar BPM si no se especificó (con seeding determinista) + if bpm is None: + rng = random.Random(self._generate_selection_seed("bpm")) + bpm = rng.randint(genre_profile.bpm_range[0], genre_profile.bpm_range[1]) + + # Crear grupo + group = InstrumentGroup( + genre=genre_profile.name, + key=key, + bpm=float(bpm) + ) + + # Seleccionar drums CON validación de roles + group.drums = self._select_drum_kit(genre, variation, target_key=key) + + # Seleccionar bass con matching armónico + group.bass = self._select_bass_samples(genre, key, bpm, count=3) + + # Seleccionar synths con diversidad + group.synths = self._select_synth_samples(genre, key, bpm, count=3) + + # Seleccionar FX + group.fx = self._select_fx_samples(genre, count=2, target_bpm=bpm) + + return group + + def _get_genre_profile(self, genre: str) -> GenreProfile: + """Obtiene el perfil de un género""" + genre_lower = genre.lower().replace(' ', '-') + + # Búsqueda exacta + if genre_lower in GENRE_PROFILES: + return GENRE_PROFILES[genre_lower] + + # Búsqueda parcial + for name, profile in GENRE_PROFILES.items(): + if genre_lower in name or name in genre_lower: + return profile + + # Fallback a techno + logger.warning(f"Género '{genre}' no encontrado, usando techno") + return GENRE_PROFILES['techno'] + + def _select_drum_kit(self, genre: str, variation: str = "standard", target_key: Optional[str] = None) -> DrumKit: + """ + Selecciona un kit de batería coherente con validación de roles. + + Mejoras Fase 4: + - Valida que cada sample sea apropiado para su rol + - Penaliza samples inapropiados (ej: snare en rol clap) + - Balancea entre one-shots preferentemente + """ + if not self.manager: + return DrumKit(name="empty") + + kit = DrumKit(name=f"{genre}_{variation}") + + # Función mejorada para encontrar drums con validación de rol + def find_drum(drum_role: str, keywords: List[str], prefer_oneshot: bool = True) -> Optional[Sample]: + all_results = [] + + # Buscar con múltiples keywords y acumular + for keyword in keywords: + results = self.manager.search( + query=keyword, + category="drums", + limit=50 + ) + all_results.extend(results) + + # Eliminar duplicados + seen_ids = set() + unique_results = [] + for s in all_results: + if s.id not in seen_ids: + seen_ids.add(s.id) + unique_results.append(s) + + if not unique_results: + return None + + # Usar el selector mejorado con validación de rol + return self._pick_ranked_sample( + unique_results, + target_key=target_key, + target_role=drum_role, # Validación de rol + target_genre=genre, + prefer_oneshot=prefer_oneshot, + pool_size=12, + context=f"drum_{drum_role}" + ) + + # Kick - siempre one-shot + kit.kick = find_drum("kick", ["kick", "bd", "bass_drum"], prefer_oneshot=True) + + # Snare o Clap según género - CON VALIDACIÓN DE ROL + if genre in ['house', 'tech-house', 'deep-house']: + # En house, clap es más común que snare + kit.clap = find_drum("clap", ["clap", "handclap"], prefer_oneshot=True) + kit.snare = find_drum("snare", ["snare", "rim"], prefer_oneshot=True) + else: + # En techno, snare es más común + kit.snare = find_drum("snare", ["snare", "rimshot"], prefer_oneshot=True) + kit.clap = find_drum("clap", ["clap"], prefer_oneshot=True) + + # Hats - validar que sean realmente hats + kit.hat_closed = find_drum("hat_closed", ["closed hat", "hihat", "hat"], prefer_oneshot=True) + kit.hat_open = find_drum("hat_open", ["open hat", "ohh"], prefer_oneshot=True) + + # Percusión adicional - validar roles + kit.perc1 = find_drum("perc", ["perc", "shaker", "tamb"], prefer_oneshot=True) + kit.perc2 = find_drum("perc", ["percussion", "conga", "bongo"], prefer_oneshot=True) + + # Tom + kit.tom = find_drum("tom", ["tom", "tomtom"], prefer_oneshot=True) + + # Crash (opcional) + kit.crash = find_drum("crash", ["crash", "cymbal"], prefer_oneshot=True) + + # Registrar roles usados + if kit.kick: + self._role_history['kick'].append(kit.kick.id) + if kit.snare: + self._role_history['snare'].append(kit.snare.id) + if kit.clap: + self._role_history['clap'].append(kit.clap.id) + + return kit + + def _select_bass_samples(self, + genre: str, + key: str, + bpm: float, + count: int = 3) -> List[Sample]: + """ + Selecciona samples de bajo compatibles con mejor ranking. + + Mejoras Fase 4: + - Matching armónico mejorado + - Balance one-shot vs loop según contexto + - Penalización de familias repetidas + """ + if not self.manager: + return [] + + # Buscar por key primero + results = self.manager.search( + category="bass", + key=key, + bpm=bpm, + bpm_tolerance=5, + limit=count * 10 + ) + + # Si no hay suficientes, buscar sin key + if len(results) < count: + more = self.manager.search( + category="bass", + bpm=bpm, + bpm_tolerance=10, + limit=count * 10 + ) + results.extend(more) + + # Buscar por género también + genre_results = self.manager.search( + category="bass", + genres=[genre], + limit=count * 8 + ) + results.extend(genre_results) + + # Eliminar duplicados + seen = set() + unique = [] + for s in results: + if s.id not in seen: + seen.add(s.id) + unique.append(s) + + # Para bass, preferimos loops en la mayoría de casos + # excepto para bass one-shots (808, stabs) + prefer_oneshot = 'trap' in genre.lower() or 'hip-hop' in genre.lower() + + return self._pick_multiple_ranked( + unique, + count=count, + target_key=key, + target_bpm=bpm, + target_genre=genre, + prefer_oneshot=prefer_oneshot, + pool_size=15, + context="bass" + ) + + def _select_synth_samples(self, + genre: str, + key: str, + bpm: float, + count: int = 3) -> List[Sample]: + """ + Selecciona samples de sintetizador compatibles con mejor ranking. + + Mejoras Fase 4: + - Diversidad de tipos (lead, pad, pluck, chord) + - Balance loops preferentemente para texturas + - Penalización de familias repetidas + """ + if not self.manager: + return [] + + # Buscar diferentes tipos de synths + synth_types = ['lead', 'pad', 'pluck', 'chord'] + results = [] + + for synth_type in synth_types: + type_results = self.manager.search( + sample_type=synth_type, + key=key, + bpm=bpm, + bpm_tolerance=5, + limit=12 + ) + results.extend(type_results) + + # Completar con búsqueda general + if len(results) < count * 2: + more = self.manager.search( + category="synths", + key=key, + limit=count * 10 + ) + results.extend(more) + + # Eliminar duplicados + seen = set() + unique = [] + for s in results: + if s.id not in seen: + seen.add(s.id) + unique.append(s) + + # Para synths, preferimos loops para pads y chords + # one-shots para leads y plucks + prefer_oneshot = False # Default a loops para texturas + + return self._pick_multiple_ranked( + unique, + count=count, + target_key=key, + target_bpm=bpm, + target_genre=genre, + prefer_oneshot=prefer_oneshot, + pool_size=15, + context="synth" + ) + + def _select_fx_samples(self, genre: str, count: int = 2, target_bpm: Optional[float] = None) -> List[Sample]: + """ + Selecciona efectos apropiados con mejor ranking. + """ + if not self.manager: + return [] + + results = self.manager.search( + category="one_shots", + sample_type="fx", + genres=[genre], + limit=count * 8 + ) + + # También buscar en category fx directamente + fx_results = self.manager.search( + category="fx", + limit=count * 6 + ) + results.extend(fx_results) + + # Eliminar duplicados + seen = set() + unique = [] + for s in results: + if s.id not in seen: + seen.add(s.id) + unique.append(s) + + return self._pick_multiple_ranked( + unique, + count=count, + target_bpm=target_bpm, + target_genre=genre, + prefer_oneshot=True, # FX generalmente son one-shots + pool_size=10, + context="fx" + ) + + def find_compatible_samples(self, + reference_sample: Sample, + sample_type: str = "", + max_results: int = 10) -> List[Tuple[Sample, float]]: + """ + Encuentra samples compatibles con uno de referencia. + + Calcula score de compatibilidad basado en: + - Key (armonía) + - BPM (tempo) + - Género + - Características de audio + """ + if not self.manager: + return [] + + # Buscar candidatos + candidates = self.manager.search( + sample_type=sample_type or reference_sample.sample_type, + limit=50 + ) + + results = [] + for candidate in candidates: + if candidate.id == reference_sample.id: + continue + + score = self._calculate_compatibility(reference_sample, candidate) + if score > 0.5: # Umbral mínimo + results.append((candidate, score)) + + # Ordenar por score + results.sort(key=lambda x: x[1], reverse=True) + return results[:max_results] + + def _calculate_compatibility(self, sample1: Sample, sample2: Sample) -> float: + """Calcula un score de compatibilidad entre dos samples""" + score = 0.0 + weights = 0.0 + + # Compatibilidad de key (peso: 0.4) + if sample1.key and sample2.key: + if MANAGER_AVAILABLE: + key_compat = calculate_key_compatibility(sample1.key, sample2.key) + else: + key_compat = 1.0 if sample1.key == sample2.key else 0.5 + score += key_compat * 0.4 + weights += 0.4 + + # Compatibilidad de BPM (peso: 0.3) + if sample1.bpm and sample2.bpm: + bpm_diff = abs(sample1.bpm - sample2.bpm) + if bpm_diff == 0: + bpm_compat = 1.0 + elif bpm_diff <= 3: + bpm_compat = 0.9 + elif bpm_diff <= 6: + bpm_compat = 0.7 + elif bpm_diff <= 10: + bpm_compat = 0.5 + else: + bpm_compat = max(0.0, 1.0 - (bpm_diff / 50)) + score += bpm_compat * 0.3 + weights += 0.3 + + # Compatibilidad de género (peso: 0.2) + if sample1.genres and sample2.genres: + common_genres = set(sample1.genres) & set(sample2.genres) + if common_genres: + genre_compat = len(common_genres) / max(len(sample1.genres), len(sample2.genres)) + score += genre_compat * 0.2 + weights += 0.2 + + # Compatibilidad de categoría (peso: 0.1) + if sample1.category == sample2.category: + score += 0.1 + weights += 0.1 + + return score / weights if weights > 0 else 0.0 + + def get_midi_mapping_for_kit(self, kit: DrumKit) -> Dict[str, Any]: + """ + Genera un mapeo MIDI completo para un kit de batería. + + Returns: + Dict con información de mapeo para Ableton + """ + mapping = { + 'kit_name': kit.name, + 'notes': {}, + 'drum_rack_slots': {}, + } + + midi_map = kit.get_midi_mapping() + + for note, sample in midi_map.items(): + note_name = self._midi_note_to_name(note) + mapping['notes'][note] = { + 'name': note_name, + 'sample': sample.name if sample else None, + 'sample_path': sample.path if sample else None, + } + + # Mapeo para Drum Rack (0-127 pads) + if note in range(36, 52): # Rango de drums común + drum_rack_slot = note - 36 + mapping['drum_rack_slots'][drum_rack_slot] = { + 'note': note, + 'sample': sample.name if sample else None, + 'sample_path': sample.path if sample else None, + } + + return mapping + + def _midi_note_to_name(self, note: int) -> str: + """Convierte número de nota MIDI a nombre""" + note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + octave = (note // 12) - 1 + name = note_names[note % 12] + return f"{name}{octave}" + + def suggest_key_change(self, + current_key: str, + direction: str = "fifth_up") -> str: + """ + Sugiere un cambio de key armónico. + + Args: + current_key: Key actual + direction: 'fifth_up', 'fifth_down', 'relative', 'parallel' + + Returns: + Nueva key sugerida + """ + # Círculo de quintas + circle_major = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F'] + circle_minor = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm'] + + is_minor = current_key.endswith('m') + root = current_key.rstrip('m') + + circle = circle_minor if is_minor else circle_major + + try: + idx = circle.index(current_key) + except ValueError: + # Intentar encontrar equivalente + return current_key + + if direction == "fifth_up": + new_idx = (idx + 1) % 12 + return circle[new_idx] + elif direction == "fifth_down": + new_idx = (idx - 1) % 12 + return circle[new_idx] + elif direction == "relative": + # Cambiar entre mayor/menor relativo + if is_minor: + # De menor a mayor relativo (3 semitonos arriba) + rel_idx = (idx + 3) % 12 + return circle_major[rel_idx] + else: + # De mayor a menor relativo (3 semitonos abajo) + rel_idx = (idx - 3) % 12 + return circle_minor[rel_idx] + elif direction == "parallel": + # Cambiar entre mayor/menor paralelo + if is_minor: + return root + else: + return root + 'm' + + return current_key + + def create_variation(self, + original_group: InstrumentGroup, + variation_type: str = "energy_up") -> InstrumentGroup: + """ + Crea una variación de un grupo de instrumentos. + + Args: + original_group: Grupo original + variation_type: Tipo de variación + + Returns: + Nuevo InstrumentGroup variado + """ + new_group = InstrumentGroup( + genre=original_group.genre, + key=original_group.key, + bpm=original_group.bpm + ) + + if variation_type == "energy_up": + # Buscar samples más intensos + new_group.drums = self._select_drum_kit( + original_group.genre, + variation="heavy", + target_key=original_group.key + ) + # Mantener key, buscar bass más agresivo + new_group.bass = self._select_bass_samples( + original_group.genre, + original_group.key, + original_group.bpm, + count=3 + ) + + elif variation_type == "breakdown": + # Reducir elementos, mantener key + new_group.drums = DrumKit(name="minimal") + new_group.drums.kick = original_group.drums.kick + new_group.drums.hat_closed = original_group.drums.hat_closed + # Solo pads y elementos atmosféricos + new_group.synths = self._select_synth_samples( + original_group.genre, + original_group.key, + original_group.bpm, + count=2 + ) + + elif variation_type == "key_change": + # Cambiar de tonalidad + new_key = self.suggest_key_change(original_group.key, "fifth_up") + new_group.key = new_key + new_group.bass = self._select_bass_samples( + original_group.genre, + new_key, + original_group.bpm, + count=3 + ) + new_group.synths = self._select_synth_samples( + original_group.genre, + new_key, + original_group.bpm, + count=3 + ) + + return new_group + + +# ============================================================================ +# Funciones de conveniencia +# ============================================================================ + +_selector: Optional[SampleSelector] = None + + +def get_selector(session_seed: Optional[int] = None) -> SampleSelector: + """Obtiene la instancia global del selector""" + global _selector + if _selector is None: + _selector = SampleSelector(session_seed=session_seed) + elif session_seed is not None: + _selector._session_seed = session_seed + _selector._selection_counter = 0 + return _selector + + +def reset_selector(): + """Resetea el selector global para una nueva sesión""" + global _selector + _selector = None + + +def select_samples_for_track(genre: str, + key: Optional[str] = None, + bpm: Optional[float] = None, + session_seed: Optional[int] = None) -> Dict[str, Any]: + """ + Selecciona samples para un track completo. + + Args: + genre: Género musical + key: Tonalidad (auto-selecciona si None) + bpm: BPM (auto-selecciona si None) + session_seed: Semilla para reproducibilidad + + Returns: + Dict con toda la información de selección + """ + selector = get_selector(session_seed=session_seed) + group = selector.select_for_genre(genre, key, bpm) + + return { + 'genre': group.genre, + 'key': group.key, + 'bpm': group.bpm, + 'drum_kit': group.drums.to_dict(), + 'midi_mapping': selector.get_midi_mapping_for_kit(group.drums), + 'bass_samples': [s.to_dict() for s in group.bass], + 'synth_samples': [s.to_dict() for s in group.synths], + 'fx_samples': [s.to_dict() for s in group.fx], + 'session_seed': selector._session_seed, + } + + +def get_drum_kit(genre: str, variation: str = "standard", key: Optional[str] = None) -> Dict[str, Any]: + """ + Obtiene un kit de batería para un género. + + Args: + genre: Género musical + variation: Variación del kit + key: Key para matching armónico + """ + selector = get_selector() + kit = selector._select_drum_kit(genre, variation, target_key=key) + + return { + 'kit': kit.to_dict(), + 'midi_mapping': selector.get_midi_mapping_for_kit(kit), + } + + +def find_compatible(sample_path: str, max_results: int = 10) -> List[Dict[str, Any]]: + """Encuentra samples compatibles con uno dado""" + selector = get_selector() + manager = get_manager() + + sample = manager.get_by_path(sample_path) + if not sample: + return [] + + compatible = selector.find_compatible_samples(sample, max_results=max_results) + return [ + { + 'sample': s.to_dict(), + 'compatibility_score': score + } + for s, score in compatible + ] + + +# ============================================================================ +# Funciones para GPU/Embeddings (opcional) +# ============================================================================ + +def calculate_embedding_similarity(samples: List['Sample'], + reference: 'Sample', + use_gpu: bool = True) -> List[Tuple['Sample', float]]: + """ + Calcula similitud de embeddings entre samples usando operaciones vectorizadas. + Requiere que los samples tengan embeddings pre-calculados. + + Args: + samples: Lista de samples a comparar + reference: Sample de referencia + use_gpu: Usar GPU si está disponible + + Returns: + Lista de (sample, similarity_score) ordenada por similitud + """ + if not NUMPY_AVAILABLE: + logger.warning("NumPy no disponible, usando similitud básica") + return [(s, 0.5) for s in samples] + + # Verificar si hay embeddings disponibles + ref_embedding = getattr(reference, 'embedding', None) + if ref_embedding is None: + logger.warning("No hay embedding de referencia, usando similitud básica") + return [(s, 0.5) for s in samples] + + results = [] + xp = cp if (use_gpu and GPU_AVAILABLE) else np + + try: + ref_vec = xp.array(ref_embedding) + ref_norm = xp.linalg.norm(ref_vec) + + for sample in samples: + sample_embedding = getattr(sample, 'embedding', None) + if sample_embedding is not None: + sample_vec = xp.array(sample_embedding) + sample_norm = xp.linalg.norm(sample_vec) + + if ref_norm > 0 and sample_norm > 0: + similarity = float(xp.dot(ref_vec, sample_vec) / (ref_norm * sample_norm)) + else: + similarity = 0.0 + else: + similarity = 0.0 + + results.append((sample, similarity)) + + # Ordenar por similitud descendente + results.sort(key=lambda x: x[1], reverse=True) + + except Exception as e: + logger.warning(f"Error calculando similitud de embeddings: {e}") + return [(s, 0.5) for s in samples] + + return results + + +def batch_score_samples(samples: List['Sample'], + target_key: Optional[str] = None, + target_bpm: Optional[float] = None, + target_genre: Optional[str] = None, + use_gpu: bool = True) -> List[Tuple['Sample', float]]: + """ + Calcula scores para múltiples samples de forma vectorizada. + Usa NumPy o CuPy para aceleración. + + Args: + samples: Lista de samples a puntuar + target_key: Key objetivo + target_bpm: BPM objetivo + target_genre: Género objetivo + use_gpu: Usar GPU si está disponible + + Returns: + Lista de (sample, score) ordenada por score descendente + """ + if not samples: + return [] + + if not NUMPY_AVAILABLE or len(samples) < 10: + # Para pocos samples, usar scoring individual + selector = get_selector() + results = [] + for sample in samples: + score = selector._calculate_sample_score( + sample, + target_key=target_key, + target_bpm=target_bpm, + target_genre=target_genre + ) + results.append((sample, score)) + results.sort(key=lambda x: x[1], reverse=True) + return results + + # Vectorized scoring con NumPy/CuPy + xp = cp if (use_gpu and GPU_AVAILABLE) else np + + ratings = xp.array([min(1.0, (s.rating or 0) / 5.0) for s in samples]) + + # Key compatibility + key_scores = xp.zeros(len(samples)) + if target_key: + for i, s in enumerate(samples): + if s.key: + if MANAGER_AVAILABLE: + key_scores[i] = calculate_key_compatibility(target_key, s.key) + else: + key_scores[i] = 1.0 if s.key == target_key else 0.5 + else: + key_scores[i] = 0.5 + + # BPM compatibility + bpm_scores = xp.zeros(len(samples)) + if target_bpm: + for i, s in enumerate(samples): + if s.bpm: + diff = abs(s.bpm - target_bpm) + if diff == 0: + bpm_scores[i] = 1.0 + elif diff <= 3: + bpm_scores[i] = 0.95 + elif diff <= 6: + bpm_scores[i] = 0.85 + elif diff <= 10: + bpm_scores[i] = 0.70 + else: + bpm_scores[i] = max(0.2, 1.0 - (diff / 30)) + else: + bpm_scores[i] = 0.5 + + # Genre compatibility + genre_scores = xp.zeros(len(samples)) + if target_genre: + genre_lower = target_genre.lower().replace(' ', '-') + for i, s in enumerate(samples): + if s.genres: + sample_genres = [g.lower().replace(' ', '-') for g in s.genres] + if genre_lower in sample_genres: + genre_scores[i] = 1.0 + elif any(g in genre_lower or genre_lower in g for g in sample_genres): + genre_scores[i] = 0.7 + else: + genre_scores[i] = 0.3 + else: + genre_scores[i] = 0.5 + + # Combined score (weighted) + weights = xp.array([0.25, 0.25, 0.25, 0.25]) # rating, key, bpm, genre + scores_matrix = xp.stack([ratings, key_scores, bpm_scores, genre_scores]) + final_scores = xp.dot(weights, scores_matrix) + + # Convertir a lista y ordenar + results = [(samples[i], float(final_scores[i])) for i in range(len(samples))] + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +# Testing +if __name__ == "__main__": + + logging.basicConfig(level=logging.INFO) + + print("Sample Selector - Test (Fase 4 mejorada)") + print("=" * 60) + + selector = SampleSelector() + + # Test de selección por género + genres = ['techno', 'house', 'tech-house', 'deep-house'] + + for genre in genres: + print(f"\n{genre.upper()}:") + profile = selector._get_genre_profile(genre) + print(f" BPM: {profile.bpm_range}") + print(f" Keys: {profile.common_keys}") + print(f" Características: {', '.join(profile.characteristics)}") + + # Test de selección completa con reproducibilidad + print("\n" + "=" * 60) + print("SELECCIÓN PARA TECHNO (session_seed=12345):") + + # Usar semilla para reproducibilidad + selector_test = SampleSelector(session_seed=12345) + group = selector_test.select_for_genre('techno', key='F#m', bpm=130) + + print(f"\nKey: {group.key}, BPM: {group.bpm}") + print(f"Session Seed: {selector_test._session_seed}") + print(f"\nDrum Kit: {group.drums.name}") + if group.drums.kick: + print(f" Kick: {group.drums.kick.name} (role validated)") + if group.drums.snare: + print(f" Snare: {group.drums.snare.name} (role validated)") + if group.drums.clap: + print(f" Clap: {group.drums.clap.name} (role validated)") + if group.drums.hat_closed: + print(f" Hat: {group.drums.hat_closed.name} (role validated)") + + print(f"\nBass samples: {len(group.bass)}") + print(f"Synth samples: {len(group.synths)}") + + # Test de reproducibilidad - segunda corrida con misma semilla + print("\n" + "=" * 60) + print("TEST DE REPRODUCIBILIDAD (misma semilla):") + + selector_test2 = SampleSelector(session_seed=12345) + group2 = selector_test2.select_for_genre('techno', key='F#m', bpm=130) + + print(f"Misma key: {group.key == group2.key}") + print(f"Mismo BPM: {group.bpm == group2.bpm}") + + # Test de validación de roles + print("\n" + "=" * 60) + print("TEST DE VALIDACIÓN DE ROLES:") + + # Crear un sample mock para testing + class MockSample: + def __init__(self, name, sample_type, category): + self.name = name + self.sample_type = sample_type + self.category = category + self.subcategory = "" + self.id = name + self.key = None + self.bpm = None + self.rating = 3 + self.genres = [] + self.rms_energy = 0.5 + self.duration = 0.5 + + # Test samples correctos + kick_sample = MockSample("Techno_Kick_01", "kick", "drums") + snare_sample = MockSample("Techno_Snare_02", "snare", "drums") + clap_sample = MockSample("Techno_Clap_03", "clap", "drums") + + print(f" Kick para rol 'kick': {selector._validate_sample_for_role(kick_sample, 'kick'):.2f}") + print(f" Snare para rol 'snare': {selector._validate_sample_for_role(snare_sample, 'snare'):.2f}") + print(f" Clap para rol 'clap': {selector._validate_sample_for_role(clap_sample, 'clap'):.2f}") + + # Test samples incorrectos (ABSURDOS) + print(f" Snare para rol 'kick': {selector._validate_sample_for_role(snare_sample, 'kick'):.2f} (debería ser bajo)") + print(f" Clap para rol 'hat_closed': {selector._validate_sample_for_role(clap_sample, 'hat_closed'):.2f} (debería ser bajo)") + + print("\n" + "=" * 60) + print(f"NumPy disponible: {NUMPY_AVAILABLE}") + print(f"GPU disponible: {GPU_AVAILABLE}") diff --git a/AbletonMCP_AI/MCP_Server/sample_system_demo.py b/AbletonMCP_AI/MCP_Server/sample_system_demo.py new file mode 100644 index 0000000..3e70974 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/sample_system_demo.py @@ -0,0 +1,244 @@ +""" +Demo del Sistema de Gestión de Samples para AbletonMCP-AI + +Este script demuestra las capacidades del sistema completo de samples. +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from sample_manager import get_manager +from sample_selector import get_selector +from audio_analyzer import analyze_sample, AudioAnalyzer + + +def demo_analyzer(): + """Demostración del analizador de audio""" + print("=" * 60) + print("DEMO: Audio Analyzer") + print("=" * 60) + + AudioAnalyzer(backend='basic') + + # Analizar un archivo de ejemplo + test_file = r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav" + + print(f"\nAnalizando: {Path(test_file).name}") + print("-" * 40) + + try: + result = analyze_sample(test_file) + + print(f"Tipo detectado: {result['sample_type']}") + print(f"BPM: {result.get('bpm') or 'No detectado'}") + print(f"Key: {result.get('key') or 'No detectado'}") + print(f"Duración: {result['duration']:.3f}s") + print(f"Es percusivo: {result['is_percussive']}") + print(f"Géneros sugeridos: {', '.join(result['suggested_genres'])}") + + except Exception as e: + print(f"Error: {e}") + + print() + + +def demo_manager(): + """Demostración del gestor de samples""" + print("=" * 60) + print("DEMO: Sample Manager") + print("=" * 60) + + manager = get_manager(r"C:\Users\ren\embeddings\all_tracks") + + # Escanear librería + print("\nEscaneando librería...") + stats = manager.scan_directory() + print(f" Samples procesados: {stats['processed']}") + print(f" Nuevos: {stats['added']}") + print(f" Total en librería: {stats['total_samples']}") + + # Estadísticas + print("\nEstadísticas:") + stats = manager.get_stats() + print(f" Total: {stats['total_samples']} samples") + print(f" Tamaño: {stats['total_size'] / (1024**2):.1f} MB") + + if stats['by_category']: + print("\n Por categoría:") + for cat, count in sorted(stats['by_category'].items(), key=lambda x: -x[1]): + print(f" {cat}: {count}") + + if stats['by_key']: + print("\n Por key:") + for key, count in sorted(stats['by_key'].items(), key=lambda x: -x[1]): + print(f" {key}: {count}") + + # Búsquedas + print("\nBúsquedas:") + print("-" * 40) + + # Buscar kicks + kicks = manager.search(sample_type="kick", limit=3) + print(f"\nKicks encontrados: {len(kicks)}") + for s in kicks: + print(f" - {s.name}") + + # Buscar por key + g_sharp = manager.search(key="G#m", limit=3) + print(f"\nSamples en G#m: {len(g_sharp)}") + for s in g_sharp: + print(f" - {s.name} ({s.sample_type})") + + # Buscar por BPM + bpm_128 = manager.search(bpm=128, bpm_tolerance=5, limit=3) + print(f"\nSamples ~128 BPM: {len(bpm_128)}") + for s in bpm_128: + key_info = f" [{s.key}]" if s.key else "" + print(f" - {s.name}{key_info}") + + print() + + +def demo_selector(): + """Demostración del selector inteligente""" + print("=" * 60) + print("DEMO: Sample Selector") + print("=" * 60) + + selector = get_selector() + + # Seleccionar para diferentes géneros + genres = ['techno', 'house', 'tech-house'] + + for genre in genres: + print(f"\n{genre.upper()}:") + print("-" * 40) + + group = selector.select_for_genre(genre, key='Am', bpm=128) + + print(f" Key: {group.key} | BPM: {group.bpm}") + + # Drum kit + kit = group.drums + print("\n Drum Kit:") + if kit.kick: + print(f" Kick: {kit.kick.name}") + if kit.snare: + print(f" Snare: {kit.snare.name}") + if kit.clap: + print(f" Clap: {kit.clap.name}") + if kit.hat_closed: + print(f" Hat: {kit.hat_closed.name}") + + # Mapeo MIDI + mapping = selector.get_midi_mapping_for_kit(kit) + print("\n Mapeo MIDI:") + for note, info in sorted(mapping['notes'].items())[:4]: + if info['sample']: + print(f" Note {note}: {info['sample'][:40]}...") + + # Bass + if group.bass: + print(f"\n Bass ({len(group.bass)}):") + for s in group.bass[:2]: + key_info = f" [{s.key}]" if s.key else "" + print(f" - {s.name}{key_info}") + + # Cambio de key + print("\n" + "-" * 40) + print("Cambios de Key Sugeridos (desde Am):") + changes = ['fifth_up', 'fifth_down', 'relative', 'parallel'] + for change in changes: + new_key = selector.suggest_key_change('Am', change) + print(f" {change}: {new_key}") + + print() + + +def demo_compatibility(): + """Demostración de búsqueda de samples compatibles""" + print("=" * 60) + print("DEMO: Compatibilidad de Samples") + print("=" * 60) + + manager = get_manager() + selector = get_selector() + + # Encontrar un sample con key para usar de referencia + samples_with_key = manager.search(key="G#m", limit=1) + + if samples_with_key: + reference = samples_with_key[0] + print(f"\nSample de referencia: {reference.name}") + print(f" Key: {reference.key} | BPM: {reference.bpm}") + + # Buscar compatibles + compatible = selector.find_compatible_samples(reference, max_results=5) + + print("\nSamples compatibles:") + print("-" * 40) + + for sample, score in compatible: + bar_len = int(score * 20) + bar = "█" * bar_len + "░" * (20 - bar_len) + print(f" [{bar}] {score:.1%} - {sample.name}") + + print() + + +def demo_pack_generation(): + """Demostración de generación de packs""" + print("=" * 60) + print("DEMO: Generación de Sample Packs") + print("=" * 60) + + manager = get_manager() + + genres = ['techno', 'house', 'deep-house'] + + for genre in genres: + print(f"\n{genre.upper()} Pack:") + print("-" * 40) + + pack = manager.get_pack_for_genre(genre, key='Am', bpm=128) + + total = 0 + for category, samples in pack.items(): + if samples: + count = len(samples) + total += count + print(f" {category}: {count}") + + print(f" Total: {total} samples") + + print() + + +def main(): + """Ejecutar todas las demos""" + print("\n") + print("=" * 60) + print(" AbletonMCP-AI Sample System Demo ".center(60)) + print("=" * 60) + print() + + try: + demo_analyzer() + demo_manager() + demo_selector() + demo_compatibility() + demo_pack_generation() + + print("=" * 60) + print("Todas las demos completadas exitosamente!") + print("=" * 60) + + except Exception as e: + print(f"\nError en demo: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI/MCP_Server/scan_audio.py b/AbletonMCP_AI/MCP_Server/scan_audio.py new file mode 100644 index 0000000..c554657 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/scan_audio.py @@ -0,0 +1,16 @@ +import sample_manager + +print('Iniciando escaneo de la libreria de samples con analyze_audio=True...') +try: + path = r'C:\Users\ren\embeddings\all_tracks' + stats = sample_manager.scan_samples(path, analyze_audio=True) + p = stats.get('processed', 0) + a = stats.get('added', 0) + u = stats.get('updated', 0) + e = stats.get('errors', 0) + print(f'Procesados: {p}') + print(f'Agregados: {a}') + print(f'Actualizados: {u}') + print(f'Errores: {e}') +except Exception as e: + print('Error:', e) diff --git a/AbletonMCP_AI/MCP_Server/scan_log.txt b/AbletonMCP_AI/MCP_Server/scan_log.txt new file mode 100644 index 0000000..b5a9314 Binary files /dev/null and b/AbletonMCP_AI/MCP_Server/scan_log.txt differ diff --git a/AbletonMCP_AI/MCP_Server/segment_rag_builder.py b/AbletonMCP_AI/MCP_Server/segment_rag_builder.py new file mode 100644 index 0000000..3f97f68 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/segment_rag_builder.py @@ -0,0 +1,198 @@ +""" +segment_rag_builder.py - Build or refresh the persistent segment-audio index. +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path + +from reference_listener import ReferenceAudioListener, export_segment_rag_manifest, generate_segment_rag_summary, _get_segment_rag_status, _backfill_segment_cache_metadata + + +logger = logging.getLogger(__name__) + + +def _default_library_dir() -> Path: + return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build the persistent segment-audio retrieval cache.") + parser.add_argument("--library-dir", default=str(_default_library_dir()), help="Audio library directory") + parser.add_argument("--roles", nargs="*", default=None, help="Subset of roles to index") + parser.add_argument("--max-files", type=int, default=None, help="Optional limit for targeted files") + parser.add_argument("--duration-limit", type=float, default=24.0, help="Max seconds per file during indexing") + parser.add_argument("--force", action="store_true", help="Rebuild even if persistent segment cache already exists") + parser.add_argument("--json", action="store_true", help="Emit full JSON report") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + parser.add_argument("--offset", type=int, default=0, help="Skip first N files before starting (for chunked indexing)") + parser.add_argument("--batch-size", type=int, default=None, help="Process exactly N files then stop (for chunked indexing)") + parser.add_argument("--output-manifest", type=str, default=None, help="Path to save full manifest JSON") + parser.add_argument("--output-summary", type=str, default=None, help="Path to save summary report") + parser.add_argument("--resume", action="store_true", help="Resume from previous run state") + parser.add_argument("--export-manifest", type=str, default=None, + help="Export candidate manifest to FILE (format: .json or .md)") + parser.add_argument("--export-format", type=str, default="json", + choices=['json', 'markdown'], help="Manifest export format") + parser.add_argument("--status", action="store_true", help="Show current index status without building") + parser.add_argument("--backfill-metadata", action="store_true", help="Backfill metadata into existing cache files from indexing state") + parser.add_argument("--force-backfill", action="store_true", help="Force backfill even for files that already have metadata") + args = parser.parse_args() + + # Configure logging based on verbose flag + if args.verbose: + logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s') + else: + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + + # Handle --status flag for early exit + if args.status: + status = _get_segment_rag_status(Path(args.library_dir)) + + if args.json: + print(json.dumps(status, indent=2, default=str)) + else: + print("=" * 60) + print("SEGMENT RAG INDEX STATUS") + print("=" * 60) + print(f"Cache Directory: {status['cache_dir']}") + print(f"Cache Files: {status['cache_files']}") + print(f"Total Indexed Segments: {status['total_segments']}") + print(f"Status: {status.get('status', 'unknown')}") + + if status.get('role_coverage'): + print("\nRole Coverage:") + for role, count in sorted(status['role_coverage'].items()): + print(f" {role}: {count} segments") + + if status.get('newest_entries'): + print(f"\nNewest Entries: {len(status['newest_entries'])} files") + for entry in status['newest_entries'][:5]: + print(f" - {entry['file_name']} ({entry['segments']} segments)") + + if status.get('oldest_entries'): + print(f"\nOldest Entries: {len(status['oldest_entries'])} files") + for entry in status['oldest_entries'][:5]: + print(f" - {entry['file_name']} ({entry['segments']} segments)") + + return 0 + + # Handle --backfill-metadata flag for early exit + if args.backfill_metadata: + result = _backfill_segment_cache_metadata(Path(args.library_dir), force=args.force_backfill) + + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print("=" * 60) + print("SEGMENT CACHE METADATA BACKFILL") + print("=" * 60) + print(f"Cache Directory: {result['cache_dir']}") + print(f"Cache Files: {result['cache_files']}") + print(f"Backfilled: {result['backfilled']}") + print(f"Skipped: {result['skipped']}") + print(f"Errors: {result['errors']}") + print(f"Status: {result.get('status', 'unknown')}") + + return 0 + + listener = ReferenceAudioListener(args.library_dir) + report = listener.build_segment_rag_index( + roles=args.roles, + max_files=args.max_files, + duration_limit=args.duration_limit, + force=args.force, + offset=args.offset, + batch_size=args.batch_size, + resume=args.resume, + ) + + # Generate enhanced summary + summary = generate_segment_rag_summary(report, Path(args.library_dir)) + + if args.json: + print(json.dumps(summary, indent=2, default=str)) + else: + # Enhanced text output + print("=" * 60) + print("SEGMENT RAG INDEX COMPLETE") + print("=" * 60) + print(f"Device: {summary['device']}") + print(f"Cache: {summary['segment_index_dir']}") + print() + print(f"Files: {summary['files_targeted']} targeted") + print(f" Built: {summary['built']}") + print(f" Reused: {summary['reused']}") + print(f" Skipped: {summary['skipped']}") + print(f" Errors: {summary['errors']}") + print() + print(f"Total Segments: {summary['total_segments']}") + + if 'summary_stats' in summary: + stats = summary['summary_stats'] + print(f" Avg per file: {stats['avg_segments_per_file']:.1f}") + print(f" Range: {stats['min_segments']} - {stats['max_segments']}") + + if 'role_coverage' in summary: + print("\nRole Coverage:") + for role in sorted(summary['role_coverage'].keys()): + print(f" {role}: {summary['role_coverage'][role]} segments") + + if 'cache_info' in summary: + info = summary['cache_info'] + print(f"\nCache Size: {info['cache_size_mb']} MB") + + if args.offset > 0: + print(f"\nOffset: {args.offset}") + if args.batch_size is not None: + print(f"Batch Size: {args.batch_size}") + print(f"Files Remaining: {summary.get('files_remaining', 'unknown')}") + + # Save manifest if requested + if args.output_manifest: + manifest_path = Path(args.output_manifest) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + with open(manifest_path, 'w') as f: + json.dump({ + "report": report, + "full_manifest": report.get("manifest", []), + }, f, indent=2) + if not args.json: + print(f"\nManifest saved to: {manifest_path}") + + # Save summary if requested + if args.output_summary: + summary_path = Path(args.output_summary) + summary_path.parent.mkdir(parents=True, exist_ok=True) + with open(summary_path, 'w') as f: + json.dump(summary, f, indent=2, default=str) + if not args.json: + print(f"Summary saved to: {summary_path}") + + # Export manifest in requested format + if args.export_manifest: + manifest_path = Path(args.export_manifest) + export_format = args.export_format + + # Determine format from extension if not specified + if not args.export_format or args.export_format == "json": + if manifest_path.suffix == '.md': + export_format = 'markdown' + else: + export_format = 'json' + + export_segment_rag_manifest( + report.get('manifest', []), + manifest_path, + format=export_format + ) + print(f"Manifest exported to: {manifest_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI/MCP_Server/server.py b/AbletonMCP_AI/MCP_Server/server.py new file mode 100644 index 0000000..805b046 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/server.py @@ -0,0 +1,7032 @@ +""" +AbletonMCP AI Server - Servidor MCP para generación musical +Integra FastMCP con Ableton Live 12 + +Para ejecutar: + python -m AbletonMCP_AI.MCP_Server.server + +O con uv: + uv run python -m AbletonMCP_AI.MCP_Server.server +""" + +from mcp.server.fastmcp import FastMCP, Context +import socket +import json +import logging +import os +import random +import shutil +import sys +import time +import ctypes +from dataclasses import dataclass +from collections import deque +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any, List, Optional, Set, Tuple, Union +from pathlib import Path + +# Añadir paths para imports directos y de paquete +# FIX: Use absolute path to ensure correct resolution regardless of execution location +PROGRAM_DATA_DIR = Path("C:/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts") +SERVER_DIR = PROGRAM_DATA_DIR / "AbletonMCP_AI" / "MCP_Server" +PACKAGE_DIR = PROGRAM_DATA_DIR / "AbletonMCP_AI" +for import_path in (str(SERVER_DIR), str(PACKAGE_DIR)): + if import_path not in sys.path: + sys.path.insert(0, import_path) + +try: + from song_generator import SongGenerator, StyleConfig + from sample_index import SampleIndex + from reference_listener import ReferenceAudioListener + from audio_resampler import AudioResampler +except ImportError: + # Fallback si no están disponibles + SongGenerator = None + SampleIndex = None + ReferenceAudioListener = None + AudioResampler = None + +# Configuración de logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("AbletonMCP-AI") + +# ============================================================================ +# ERROR HANDLING INFRASTRUCTURE +# ============================================================================ + +class MCPError(Exception): + """Base exception for MCP tool errors with structured error response.""" + + def __init__(self, message: str, error_code: str = "GENERAL_ERROR", details: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.message = message + self.error_code = error_code + self.details = details or {} + + def to_response(self) -> str: + """Return a structured error message for MCP clients.""" + return f"[ERROR:{self.error_code}] {self.message}" + + +class ConnectionError(MCPError): + """Error connecting to Ableton Live.""" + + def __init__(self, message: str = "Cannot connect to Ableton Live", details: Optional[Dict[str, Any]] = None): + super().__init__(message, "CONNECTION_ERROR", details) + + +class ValidationError(MCPError): + """Invalid parameter value.""" + + def __init__(self, param_name: str, value: Any, expected: str, details: Optional[Dict[str, Any]] = None): + message = f"Invalid parameter '{param_name}': got '{value}', expected {expected}" + super().__init__(message, "VALIDATION_ERROR", details) + self.param_name = param_name + self.value = value + self.expected = expected + + +class TimeoutError(MCPError): + """Operation timed out.""" + + def __init__(self, operation: str, timeout_seconds: float, details: Optional[Dict[str, Any]] = None): + message = f"Operation '{operation}' timed out after {timeout_seconds}s" + super().__init__(message, "TIMEOUT_ERROR", details) + self.operation = operation + self.timeout_seconds = timeout_seconds + + +class DependencyError(MCPError): + """Required dependency/module not available.""" + + def __init__(self, module_name: str, details: Optional[Dict[str, Any]] = None): + message = f"Required module '{module_name}' is not available" + super().__init__(message, "DEPENDENCY_ERROR", details) + self.module_name = module_name + + +class AbletonResponseError(MCPError): + """Ableton returned an error response.""" + + def __init__(self, command: str, response: Dict[str, Any], details: Optional[Dict[str, Any]] = None): + message = response.get("message", f"Ableton error for command '{command}'") + super().__init__(message, "ABLETON_ERROR", details) + self.command = command + self.response = response + + +def _log_error(error: Exception, context: str = "", include_traceback: bool = True) -> None: + """Log an error with optional context and traceback.""" + error_type = type(error).__name__ + error_msg = str(error) + + if context: + logger.error(f"[{context}] {error_type}: {error_msg}") + else: + logger.error(f"{error_type}: {error_msg}") + + if include_traceback and logger.isEnabledFor(logging.DEBUG): + import traceback + logger.debug(traceback.format_exc()) + + +def _validate_range(value: Any, name: str, min_val: float, max_val: float) -> float: + """Validate that a value is within a range.""" + try: + num_val = float(value) + except (TypeError, ValueError): + raise ValidationError(name, value, f"number between {min_val} and {max_val}") + + if not min_val <= num_val <= max_val: + raise ValidationError(name, value, f"number between {min_val} and {max_val}") + + return num_val + + +def _linear_to_live_slider(linear_vol: float) -> float: + """ + Convierte una amplitud lineal (0.0 - 1.0) al valor de slider de Ableton (0.0 - 1.0). + En la API de Ableton, un valor de slider de 0.85 equivale a 0 dB. + + Los valores en ROLE_GAIN_CALIBRATION ya estan calibrados donde kick=0.85 es el ancla. + Solo aplicamos la curva de potencia (sqrt) para la percepcion logaritmica del volumen. + No multiplicamos por 0.85 porque los valores de configuracion ya estan en la escala correcta. + """ + if linear_vol <= 0.001: + return 0.0 + clamped = max(0.0, min(1.0, linear_vol)) + return round(clamped ** 0.5, 3) + +def _linear_to_live_slider_bus(linear_vol: float) -> float: + """ + Similar a slider normal, pero sin el factor de atenuacion de 0.85, + ideado especificamente para compensar el headroom de los Buses RCA. + """ + if linear_vol <= 0.001: + return 0.0 + clamped = max(0.0, min(1.0, linear_vol)) + return round(clamped ** 0.5, 3) + + +def _validate_int(value: Any, name: str, min_val: int = None, max_val: int = None) -> int: + """Validate that a value is an integer within optional bounds.""" + try: + int_val = int(value) + except (TypeError, ValueError): + raise ValidationError(name, value, "integer") + + if min_val is not None and int_val < min_val: + raise ValidationError(name, value, f"integer >= {min_val}") + if max_val is not None and int_val > max_val: + raise ValidationError(name, value, f"integer <= {max_val}") + + return int_val + + +def _validate_string(value: Any, name: str, allow_empty: bool = False) -> str: + """Validate that a value is a string.""" + if value is None: + if allow_empty: + return "" + raise ValidationError(name, value, "non-empty string") + + str_val = str(value).strip() + if not allow_empty and not str_val: + raise ValidationError(name, value, "non-empty string") + + return str_val + + +def _validate_json(value: Any, name: str) -> Any: + """Validate and parse a JSON string.""" + if isinstance(value, (dict, list)): + return value + + try: + return json.loads(str(value)) + except json.JSONDecodeError as e: + raise ValidationError(name, value, f"valid JSON: {e}") + + +def _handle_tool_error(error: Exception, operation: str = "") -> str: + """Handle errors in MCP tools and return user-friendly message.""" + _log_error(error, context=operation) + + if isinstance(error, MCPError): + return error.to_response() + + return f"[ERROR:GENERAL_ERROR] {operation}: {str(error)}" + +# ============================================================================ +# GENERATION MANIFEST STORAGE +# ============================================================================ + +# Manifest de la última generación +_last_generation_manifest: Dict[str, Any] = {} + +def _store_generation_manifest(manifest: Dict[str, Any]) -> None: + """Almacena el manifest de la generación actual.""" + global _last_generation_manifest + _last_generation_manifest = manifest.copy() + logger.debug("Stored generation manifest with %d keys", len(manifest)) + +def _get_stored_manifest() -> Dict[str, Any]: + """Retorna el manifest de la última generación.""" + return _last_generation_manifest.copy() + +def _build_transition_event_summary(config: Dict[str, Any]) -> Dict[str, Any]: + """ + Build summary of transition events from config. + + Returns dict with: + - total_events: int + - event_types: list of unique fill types used + - count_by_type: dict of fill type -> count + - track_roles: list of roles that received transition material + - note_count: total number of notes across all events + """ + transition_events = config.get('transition_events', []) + + if not transition_events: + return { + 'total_events': 0, + 'event_types': [], + 'count_by_type': {}, + 'track_roles': [], + 'note_count': 0 + } + + # Count by fill type + count_by_type: Dict[str, int] = {} + track_roles: set = set() + total_notes = 0 + + for event in transition_events: + fill_name = event.get('fill', 'unknown') + count_by_type[fill_name] = count_by_type.get(fill_name, 0) + 1 + + # Track roles that received material + if 'materialized_track_roles' in event: + roles = event.get('materialized_track_roles', []) + else: + roles = event.get('roles', []) + if isinstance(roles, list): + track_roles.update(roles) + + # Count notes if available + notes_count = event.get('materialized_notes_count', event.get('notes_count', 0)) + if isinstance(notes_count, (int, float)): + total_notes += int(notes_count) + + return { + 'total_events': len(transition_events), + 'event_types': list(count_by_type.keys()), + 'count_by_type': count_by_type, + 'track_roles': sorted(list(track_roles)), + 'note_count': total_notes, + 'materialized': bool(config.get('transition_materialization', {}).get('materialized', total_notes > 0)), + } + +# Importar nuevo sistema de samples +try: + from .sample_manager import SampleManager, get_manager as get_sample_manager + from .sample_selector import ( + SampleSelector, + get_selector, + select_samples_for_track, + get_drum_kit, + reset_cross_generation_memory, + ) + from .audio_analyzer import analyze_sample, AudioAnalyzer + sample_manager_factory = get_sample_manager + SAMPLE_SYSTEM_AVAILABLE = True +except ImportError: + try: + from sample_manager import SampleManager, get_manager as get_sample_manager + from sample_selector import ( + SampleSelector, + get_selector, + select_samples_for_track, + get_drum_kit, + reset_cross_generation_memory, + ) + from audio_analyzer import analyze_sample, AudioAnalyzer + sample_manager_factory = get_sample_manager + SAMPLE_SYSTEM_AVAILABLE = True + except ImportError as e2: + logger.warning(f"Sistema de samples no disponible: {e2}") + SampleManager = None + SampleSelector = None + AudioAnalyzer = None + analyze_sample = None + get_selector = None + select_samples_for_track = None + get_drum_kit = None + reset_cross_generation_memory = None + sample_manager_factory = None + SAMPLE_SYSTEM_AVAILABLE = False + + +# Importar sistema de role matching (Phase 4) +try: + from .role_matcher import ( + validate_role_for_sample, + log_matching_decision, + enhance_sample_matching, + resolve_role_from_alias, + get_bus_for_role, + filter_aggressive_samples, + create_enhanced_match_report, + get_role_info, + VALID_ROLES, + ROLE_ALIASES, + ROLE_SCORE_THRESHOLDS, + AGGRESSIVE_KEYWORDS, + GENRE_APPROPRIATE_AGGRESSIVE, + ) + ROLE_MATCHER_AVAILABLE = True +except ImportError: + try: + from role_matcher import ( + validate_role_for_sample, + log_matching_decision, + enhance_sample_matching, + resolve_role_from_alias, + get_bus_for_role, + filter_aggressive_samples, + create_enhanced_match_report, + get_role_info, + VALID_ROLES, + ROLE_ALIASES, + ROLE_SCORE_THRESHOLDS, + AGGRESSIVE_KEYWORDS, + GENRE_APPROPRIATE_AGGRESSIVE, + ) + ROLE_MATCHER_AVAILABLE = True + except ImportError as e2: + logger.warning(f"Role matcher no disponible: {e2}") + validate_role_for_sample = None + log_matching_decision = None + enhance_sample_matching = None + resolve_role_from_alias = None + get_bus_for_role = None + filter_aggressive_samples = None + create_enhanced_match_report = None + get_role_info = None + VALID_ROLES = {} + ROLE_ALIASES = {} + ROLE_SCORE_THRESHOLDS = {} + AGGRESSIVE_KEYWORDS = set() + GENRE_APPROPRIATE_AGGRESSIVE = set() + ROLE_MATCHER_AVAILABLE = False + +# Constantes +DEFAULT_PORT = 9877 +HOST = "127.0.0.1" +PROJECT_SAMPLES_DIR = PACKAGE_DIR.parent / "librerias" / "all_tracks" +SAMPLES_DIR = str(PROJECT_SAMPLES_DIR) +MESSAGE_TERMINATOR = b"\n" +M4L_SAMPLER_PORT = 9879 +M4L_DEVICE_NAME = "AbletonMCP_SamplerPro" +USER_LIBRARY_DIR = Path.home() / "Documents" / "Ableton" / "User Library" +M4L_MAX_AUDIO_EFFECT_DIR = USER_LIBRARY_DIR / "Presets" / "Audio Effects" / "Max Audio Effect" +PROJECT_M4L_DIR = PACKAGE_DIR / "MaxForLive" +PROJECT_M4L_SAMPLER_DEVICE = PROJECT_M4L_DIR / f"{M4L_DEVICE_NAME}.amxd" +INSTALLED_M4L_SAMPLER_DEVICE = M4L_MAX_AUDIO_EFFECT_DIR / f"{M4L_DEVICE_NAME}.amxd" +ABLETON_RESOURCES_DIR = PACKAGE_DIR.parent.parent +FACTORY_M4L_MAX_AUDIO_EFFECT_DIR = ( + ABLETON_RESOURCES_DIR / "Max" / "resources" / "packages" / "Max for Live" / "patchers" / "Max Audio Effect" +) +FACTORY_M4L_SAMPLER_DEVICE = FACTORY_M4L_MAX_AUDIO_EFFECT_DIR / f"{M4L_DEVICE_NAME}.amxd" +HYBRID_DRUM_TRACK_NAME = "HYBRID DRUMS" +HYBRID_DRUM_TRACK_COLOR = 20 +AUDIO_FALLBACK_TRACK_SPECS = ( + ("AUDIO KICK", "kick", 10, 0.9), + ("AUDIO CLAP", "snare", 45, 0.78), + ("AUDIO HAT", "hat", 5, 0.64), + ("AUDIO BASS", "bass", 30, 0.82), +) +AUDIO_OPTIONAL_FALLBACK_TRACK_SPECS = ( + ("AUDIO PERC MAIN", "perc_loop", 20, 0.68), + ("AUDIO PERC ALT", "perc_alt", 22, 0.62), + ("AUDIO TOP LOOP", "top_loop", 24, 0.54), + ("AUDIO SYNTH LOOP", "synth_loop", 50, 0.52), + ("AUDIO SYNTH PEAK", "synth_peak", 52, 0.5), + ("AUDIO VOCAL LOOP", "vocal_loop", 40, 0.62), + ("AUDIO VOCAL BUILD", "vocal_build", 42, 0.58), + ("AUDIO VOCAL PEAK", "vocal_peak", 43, 0.6), + ("AUDIO CRASH FX", "crash_fx", 26, 0.46), + ("AUDIO TRANSITION FILL", "fill_fx", 28, 0.52), + ("AUDIO SNARE ROLL", "snare_roll", 27, 0.5), + ("AUDIO ATMOS", "atmos_fx", 54, 0.44), + ("AUDIO VOCAL SHOT", "vocal_shot", 41, 0.52), +) +REFERENCE_AUDIO_MUTE_MAP = { + "AUDIO KICK": ("KICK",), + "AUDIO CLAP": ("CLAP",), + "AUDIO HAT": ("HAT CLOSED", "HAT OPEN", "TOP LOOP"), + "AUDIO BASS LOOP": ("BASS", "SUB BASS"), + "AUDIO PERC MAIN": ("PERC", "PERCUSSION"), + "AUDIO PERC ALT": ("RIDE",), + "AUDIO TOP LOOP": ("TOP LOOP", "HAT OPEN", "PERCUSSION"), + "AUDIO SYNTH LOOP": ("STAB", "COUNTER", "PLUCK", "ARP"), + "AUDIO SYNTH PEAK": ("LEAD", "STAB", "COUNTER", "PLUCK", "CHORDS", "ARP"), + "AUDIO VOCAL LOOP": ("VOCAL", "VOCAL CHOP"), + "AUDIO VOCAL BUILD": ("VOCAL", "VOCAL CHOP", "ATMOS"), + "AUDIO VOCAL PEAK": ("VOCAL", "VOCAL CHOP", "LEAD"), + "AUDIO CRASH FX": ("CRASH", "IMPACT FX"), + "AUDIO TRANSITION FILL": ("TOM FILL", "SNARE FILL", "REVERSE FX"), + "AUDIO SNARE ROLL": ("SNARE FILL", "RISER FX"), + "AUDIO ATMOS": ("ATMOS", "DRONE", "PAD"), + "AUDIO VOCAL SHOT": ("VOCAL", "VOCAL CHOP", "COUNTER"), + "AUDIO RESAMPLE REVERSE FX": ("REVERSE FX", "RISER FX", "IMPACT FX"), + "AUDIO RESAMPLE RISER": ("RISER FX", "REVERSE FX", "ATMOS"), + "AUDIO RESAMPLE DOWNLIFTER": ("ATMOS", "REVERSE FX", "IMPACT FX"), + "AUDIO RESAMPLE STUTTER": ("VOCAL", "VOCAL CHOP", "COUNTER"), +} + +AUDIO_TRACK_BUS_KEYS = { + "AUDIO KICK": "drums", + "AUDIO CLAP": "drums", + "AUDIO HAT": "drums", + "AUDIO PERC": "drums", + "AUDIO PERC MAIN": "drums", + "AUDIO PERC ALT": "drums", + "AUDIO TOP LOOP": "drums", + "AUDIO CRASH FX": "drums", + "AUDIO TRANSITION FILL": "drums", + "AUDIO SNARE ROLL": "drums", + "AUDIO BASS": "bass", + "AUDIO BASS LOOP": "bass", + "AUDIO SYNTH LOOP": "music", + "AUDIO SYNTH PEAK": "music", + "AUDIO VOCAL": "vocal", + "AUDIO VOCAL LOOP": "vocal", + "AUDIO VOCAL BUILD": "vocal", + "AUDIO VOCAL PEAK": "vocal", + "AUDIO VOCAL SHOT": "vocal", + "AUDIO ATMOS": "fx", + "AUDIO RESAMPLE REVERSE FX": "fx", + "AUDIO RESAMPLE RISER": "fx", + "AUDIO RESAMPLE DOWNLIFTER": "fx", + "AUDIO RESAMPLE STUTTER": "vocal", + HYBRID_DRUM_TRACK_NAME.upper(): "drums", +} + +BUS_ROUTING_MAP = { + "kick": {"drums"}, + "snare": {"drums"}, + "clap": {"drums"}, + "hat": {"drums"}, + "perc": {"drums"}, + "ride": {"drums"}, + "tom": {"drums"}, + "crash": {"drums", "fx"}, + "sub_bass": {"bass"}, + "bass": {"bass"}, + "chords": {"music"}, + "pad": {"music"}, + "pluck": {"music"}, + "lead": {"music"}, + "arp": {"music"}, + "drone": {"music"}, + "stab": {"music"}, + "counter": {"music"}, + "vocal": {"vocal"}, + "vocal_chop": {"vocal"}, + "reverse_fx": {"fx"}, + "riser": {"fx"}, + "impact": {"fx"}, + "atmos": {"fx"}, +} + +COMMAND_TIMEOUTS = { + "reset": 30.0, + "generate_complete_song": 180.0, + "create_arrangement_audio_pattern": 45.0, + "load_device": 45.0, +} +_RECENT_LIBRARY_MATCHES = deque(maxlen=32) + +# AUDIO_LAYER_MIX_PROFILES - Calibrated for consistent gain staging +# Volumes aligned with ROLE_GAIN_CALIBRATION hierarchy +# Kick/bass as anchors, supporting elements progressively lower +# Headroom preserved for bus and master processing +AUDIO_LAYER_MIX_PROFILES = { + # DRUMS - Anchor elements at top of hierarchy + "AUDIO KICK": { + "pan": 0.0, + "volume": 0.85, # Anchor: same as kick MIDI + "sends": {"heat": 0.08, "glue": 0.08}, + "fx_chain": [ + {"device": "Saturator", "parameters": {"Drive": 1.5}}, + ], + }, + "AUDIO CLAP": { + "pan": 0.0, + "volume": 0.78, # -1.5dB relativo a kick + "sends": {"space": 0.10, "echo": 0.04, "glue": 0.08}, + "fx_chain": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.06}}, + ], + }, + "AUDIO HAT": { + "pan": 0.12, + "volume": 0.65, # -4dB relativo a kick + "sends": {"space": 0.04, "echo": 0.08, "glue": 0.04}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 12000.0, "Dry/Wet": 0.14}}, + ], + }, + # BASS - Below drums + "AUDIO BASS": { + "pan": 0.0, + "volume": 0.78, # -1dB relativo a kick, same as bass MIDI + "sends": {"heat": 0.10, "glue": 0.10}, + "fx_chain": [ + {"device": "Saturator", "parameters": {"Drive": 2.0}}, + {"device": "Auto Filter", "parameters": {"Frequency": 7800.0, "Dry/Wet": 0.08}}, + ], + }, + "AUDIO BASS LOOP": { + "pan": 0.0, + "volume": 0.78, # Same as bass + "sends": {"heat": 0.12, "glue": 0.10}, + "fx_chain": [ + {"device": "Saturator", "parameters": {"Drive": 2.2}}, + {"device": "Auto Filter", "parameters": {"Frequency": 7600.0, "Dry/Wet": 0.10}}, + ], + }, + # PERCUSSION - Secondary rhythmic elements + "AUDIO PERC": { + "pan": 0.10, + "volume": 0.68, # -3.5dB + "sends": {"space": 0.08, "echo": 0.10, "glue": 0.06}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 9500.0, "Dry/Wet": 0.12}}, + ], + }, + "AUDIO PERC MAIN": { + "pan": 0.12, + "volume": 0.68, # -3.5dB + "sends": {"space": 0.08, "echo": 0.10, "glue": 0.06}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 9800.0, "Dry/Wet": 0.12}}, + ], + }, + "AUDIO PERC ALT": { + "pan": -0.12, + "volume": 0.62, # -5dB, secondary perc + "sends": {"space": 0.12, "echo": 0.14}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.10}}, + ], + }, + "AUDIO TOP LOOP": { + "pan": -0.18, + "volume": 0.58, # -5.5dB, supporting rhythmic layer + "sends": {"space": 0.08, "echo": 0.16, "glue": 0.04}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 11200.0, "Dry/Wet": 0.16}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.06}}, + ], + }, + # MUSIC - Harmony layers below rhythm + "AUDIO SYNTH LOOP": { + "pan": -0.08, + "volume": 0.65, # -4dB + "sends": {"space": 0.12, "echo": 0.14, "glue": 0.04}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 10500.0, "Dry/Wet": 0.14}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.08}}, + ], + }, + "AUDIO SYNTH PEAK": { + "pan": 0.14, + "volume": 0.68, # -3.5dB, lead element + "sends": {"space": 0.16, "echo": 0.16, "glue": 0.05}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 9800.0, "Dry/Wet": 0.16}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.12}}, + ], + }, + # VOCAL - Present but under drums + "AUDIO VOCAL": { + "pan": 0.08, + "volume": 0.68, # -3dB + "sends": {"space": 0.14, "echo": 0.18}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.12}}, + ], + }, + "AUDIO VOCAL LOOP": { + "pan": 0.08, + "volume": 0.68, + "sends": {"space": 0.14, "echo": 0.20}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.14}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.06}}, + ], + }, + "AUDIO VOCAL BUILD": { + "pan": -0.08, + "volume": 0.65, # Lower during build + "sends": {"space": 0.18, "echo": 0.22}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.16}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.08}}, + ], + }, + "AUDIO VOCAL PEAK": { + "pan": 0.0, + "volume": 0.70, # Higher during peak + "sends": {"space": 0.16, "echo": 0.18, "glue": 0.03}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.10}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.05}}, + ], + }, + # FX - Deep in the mix + "AUDIO CRASH FX": { + "pan": 0.0, + "volume": 0.50, # -7dB, transient + "sends": {"space": 0.22, "echo": 0.10, "glue": 0.03}, + "fx_chain": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.10}}, + ], + }, + "AUDIO TRANSITION FILL": { + "pan": -0.06, + "volume": 0.55, # -6dB + "sends": {"space": 0.12, "echo": 0.14, "heat": 0.06}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 9200.0, "Dry/Wet": 0.12}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.06}}, + ], + }, + "AUDIO SNARE ROLL": { + "pan": 0.0, + "volume": 0.60, # -5dB, build tension + "sends": {"space": 0.10, "echo": 0.20, "heat": 0.04}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 10800.0, "Dry/Wet": 0.14}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.10}}, + ], + }, + "AUDIO ATMOS": { + "pan": -0.12, + "volume": 0.48, # -8dB, background texture + "sends": {"space": 0.28, "echo": 0.06, "glue": 0.02}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 7800.0, "Dry/Wet": 0.14}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.10}}, + ], + }, + "AUDIO VOCAL SHOT": { + "pan": 0.10, + "volume": 0.62, # -5dB + "sends": {"space": 0.18, "echo": 0.22}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.14}}, + {"device": "Auto Filter", "parameters": {"Frequency": 9800.0, "Dry/Wet": 0.12}}, + ], + }, + # RESAMPLE - Derived FX layers, deep in mix + "AUDIO RESAMPLE REVERSE FX": { + "volume": 0.48, # -8dB, effect layer + "pan": 0.0, + "sends": {"space": 0.32, "echo": 0.18, "heat": 0.06}, + "fx_chain": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.18}}, + {"device": "Auto Filter", "parameters": {"Frequency": 9400.0, "Dry/Wet": 0.10}}, + {"device": "Saturator", "parameters": {"Drive": 1.4}}, + ], + }, + "AUDIO RESAMPLE RISER": { + "volume": 0.52, # -7dB, builds up naturally + "pan": 0.0, + "sends": {"space": 0.36, "echo": 0.24, "heat": 0.08}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.18}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.14}}, + {"device": "Saturator", "parameters": {"Drive": 2.0}}, + ], + }, + "AUDIO RESAMPLE DOWNLIFTER": { + "volume": 0.45, # -9dB, transitional + "pan": -0.08, + "sends": {"space": 0.28, "echo": 0.12}, + "fx_chain": [ + {"device": "Auto Filter", "parameters": {"Frequency": 8800.0, "Dry/Wet": 0.14}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.12}}, + ], + }, + "AUDIO RESAMPLE STUTTER": { + "volume": 0.50, # -8dB + "pan": 0.12, + "sends": {"space": 0.18, "echo": 0.32, "glue": 0.04}, + "fx_chain": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.24}}, + {"device": "Auto Filter", "parameters": {"Frequency": 10600.0, "Dry/Wet": 0.10}}, + {"device": "Saturator", "parameters": {"Drive": 1.2}}, + ], + }, +} + +TRACK_INDEX_COMMANDS = { + "set_track_name", + "set_track_color", + "set_track_volume", + "set_track_pan", + "set_track_send", + "set_track_mute", + "set_track_solo", + "set_track_arm", + "delete_track", +} + +CLIP_SCENE_COMMANDS = { + "create_clip", + "delete_clip", + "duplicate_clip", + "set_clip_name", + "set_clip_color", + "fire_clip", + "stop_clip", + "add_notes", + "get_notes", + "remove_notes", + "set_notes", + "quantize_notes", +} + +SCENE_INDEX_COMMANDS = { + "create_scene", + "delete_scene", + "fire_scene", + "set_scene_name", + "set_scene_color", +} + +SONG_STRUCTURE_PRESETS = { + "minimal": [ + ("INTRO", 8, 12), + ("GROOVE", 16, 20), + ("BREAK", 8, 25), + ("OUTRO", 8, 8), + ], + "standard": [ + ("INTRO", 8, 12), + ("BUILD", 8, 18), + ("DROP A", 16, 28), + ("BREAK", 8, 25), + ("DROP B", 16, 30), + ("OUTRO", 8, 8), + ], + "extended": [ + ("INTRO DJ", 16, 10), + ("BUILD A", 8, 18), + ("DROP A", 16, 28), + ("BREAKDOWN", 8, 25), + ("BUILD B", 8, 18), + ("DROP B", 16, 30), + ("OUTRO DJ", 16, 8), + ], + "club": [ + ("INTRO DJ", 16, 10), + ("GROOVE A", 16, 14), + ("VOCAL BUILD", 8, 18), + ("DROP A", 16, 28), + ("BREAKDOWN", 8, 25), + ("BUILD B", 8, 18), + ("DROP B", 16, 30), + ("PEAK", 8, 32), + ("OUTRO DJ", 16, 8), + ], +} + +# Perfiles de mezcla por genero +MIX_PROFILES = { + "tech-house": { + "bus_config": { + "drums": {"gain_db": 0.0, "pan": 0.0, "color": 10}, + "bass": {"gain_db": -0.5, "pan": 0.0, "color": 30}, + "music": {"gain_db": -2.0, "pan": 0.0, "color": 45}, + "vocal": {"gain_db": -3.0, "pan": 0.0, "color": 60}, + "fx": {"gain_db": -4.0, "pan": 0.0, "color": 75}, + }, + "returns": { + "heat": {"type": "Saturator", "gain_db": 0.0, "dry_wet": 1.0}, + "glue": {"type": "Glue Compressor", "gain_db": 0.0, "dry_wet": 0.3}, + "space": {"type": "Hybrid Reverb", "gain_db": -3.0, "dry_wet": 0.5}, + "echo": {"type": "Echo", "gain_db": -6.0, "dry_wet": 0.4}, + }, + "device_chains": { + "drums": [ + {"device": "Drum Buss", "parameters": {"Drive": 2.5, "Comp": 0.4}}, + {"device": "Saturator", "parameters": {"Drive": 2.0, "Dry/Wet": 0.15}}, + ], + "bass": [ + {"device": "Saturator", "parameters": {"Drive": 3.0, "Dry/Wet": 0.2}}, + {"device": "Auto Filter", "parameters": {"Frequency": 120.0, "Resonance": 0.3}}, + ], + "music": [ + {"device": "Auto Filter", "parameters": {"Frequency": 8000.0, "Dry/Wet": 0.1}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.12}}, + ], + "vocal": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.18}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.1}}, + ], + "fx": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.25}}, + ], + }, + "automation_defaults": { + "intro": {"filter_cutoff_mult": 0.6, "reverb_wet_mult": 1.2, "delay_wet_mult": 0.8}, + "build": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 1.4, "delay_wet_mult": 1.2}, + "drop": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 0.6, "delay_wet_mult": 0.5}, + "break": {"filter_cutoff_mult": 0.5, "reverb_wet_mult": 1.5, "delay_wet_mult": 1.0}, + "outro": {"filter_cutoff_mult": 0.7, "reverb_wet_mult": 1.3, "delay_wet_mult": 1.1}, + }, + "loudness_target": { + "integrated_lufs": -8.0, + "true_peak_db": -1.0, + "lra": 6.0, + }, + }, + "house": { + "bus_config": { + "drums": {"gain_db": 0.0, "pan": 0.0, "color": 10}, + "bass": {"gain_db": 0.0, "pan": 0.0, "color": 30}, + "music": {"gain_db": -1.5, "pan": 0.0, "color": 45}, + "vocal": {"gain_db": -2.0, "pan": 0.0, "color": 60}, + "fx": {"gain_db": -3.5, "pan": 0.0, "color": 75}, + }, + "returns": { + "heat": {"type": "Saturator", "gain_db": 0.0, "dry_wet": 1.0}, + "glue": {"type": "Glue Compressor", "gain_db": 0.0, "dry_wet": 0.25}, + "space": {"type": "Hybrid Reverb", "gain_db": -2.0, "dry_wet": 0.45}, + "echo": {"type": "Echo", "gain_db": -5.0, "dry_wet": 0.35}, + }, + "device_chains": { + "drums": [ + {"device": "Drum Buss", "parameters": {"Drive": 2.0, "Comp": 0.35}}, + ], + "bass": [ + {"device": "Saturator", "parameters": {"Drive": 2.5, "Dry/Wet": 0.18}}, + ], + "music": [ + {"device": "Auto Filter", "parameters": {"Frequency": 9000.0, "Dry/Wet": 0.12}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.15}}, + ], + "vocal": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.2}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.15}}, + ], + "fx": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.3}}, + ], + }, + "automation_defaults": { + "intro": {"filter_cutoff_mult": 0.65, "reverb_wet_mult": 1.1, "delay_wet_mult": 0.9}, + "build": {"filter_cutoff_mult": 0.95, "reverb_wet_mult": 1.3, "delay_wet_mult": 1.1}, + "drop": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 0.7, "delay_wet_mult": 0.6}, + "break": {"filter_cutoff_mult": 0.55, "reverb_wet_mult": 1.4, "delay_wet_mult": 0.9}, + "outro": {"filter_cutoff_mult": 0.75, "reverb_wet_mult": 1.2, "delay_wet_mult": 1.0}, + }, + "loudness_target": { + "integrated_lufs": -7.0, + "true_peak_db": -0.5, + "lra": 5.5, + }, + }, + "techno": { + "bus_config": { + "drums": {"gain_db": 0.5, "pan": 0.0, "color": 10}, + "bass": {"gain_db": -0.5, "pan": 0.0, "color": 30}, + "music": {"gain_db": -2.5, "pan": 0.0, "color": 45}, + "vocal": {"gain_db": -4.0, "pan": 0.0, "color": 60}, + "fx": {"gain_db": -3.0, "pan": 0.0, "color": 75}, + }, + "returns": { + "heat": {"type": "Saturator", "gain_db": 1.0, "dry_wet": 1.0}, + "glue": {"type": "Glue Compressor", "gain_db": 0.0, "dry_wet": 0.4}, + "space": {"type": "Hybrid Reverb", "gain_db": -4.0, "dry_wet": 0.55}, + "echo": {"type": "Echo", "gain_db": -8.0, "dry_wet": 0.45}, + }, + "device_chains": { + "drums": [ + {"device": "Drum Buss", "parameters": {"Drive": 3.5, "Comp": 0.5}}, + {"device": "Saturator", "parameters": {"Drive": 3.0, "Dry/Wet": 0.2}}, + ], + "bass": [ + {"device": "Saturator", "parameters": {"Drive": 4.0, "Dry/Wet": 0.25}}, + {"device": "Auto Filter", "parameters": {"Frequency": 150.0, "Resonance": 0.4}}, + ], + "music": [ + {"device": "Auto Filter", "parameters": {"Frequency": 7000.0, "Dry/Wet": 0.15}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.2, "Feedback": 0.5}}, + ], + "vocal": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.25, "Feedback": 0.4}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.12}}, + ], + "fx": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.35}}, + {"device": "Saturator", "parameters": {"Drive": 2.0, "Dry/Wet": 0.15}}, + ], + }, + "automation_defaults": { + "intro": {"filter_cutoff_mult": 0.5, "reverb_wet_mult": 1.3, "delay_wet_mult": 1.0}, + "build": {"filter_cutoff_mult": 0.9, "reverb_wet_mult": 1.5, "delay_wet_mult": 1.3}, + "drop": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 0.5, "delay_wet_mult": 0.4}, + "break": {"filter_cutoff_mult": 0.4, "reverb_wet_mult": 1.6, "delay_wet_mult": 1.2}, + "outro": {"filter_cutoff_mult": 0.6, "reverb_wet_mult": 1.4, "delay_wet_mult": 1.1}, + }, + "loudness_target": { + "integrated_lufs": -9.0, + "true_peak_db": -1.5, + "lra": 7.0, + }, + }, + "progressive": { + "bus_config": { + "drums": {"gain_db": -0.5, "pan": 0.0, "color": 10}, + "bass": {"gain_db": -1.0, "pan": 0.0, "color": 30}, + "music": {"gain_db": -1.0, "pan": 0.0, "color": 45}, + "vocal": {"gain_db": -1.5, "pan": 0.0, "color": 60}, + "fx": {"gain_db": -2.5, "pan": 0.0, "color": 75}, + }, + "returns": { + "heat": {"type": "Saturator", "gain_db": -1.0, "dry_wet": 1.0}, + "glue": {"type": "Glue Compressor", "gain_db": 0.0, "dry_wet": 0.2}, + "space": {"type": "Hybrid Reverb", "gain_db": -1.0, "dry_wet": 0.6}, + "echo": {"type": "Echo", "gain_db": -4.0, "dry_wet": 0.5}, + }, + "device_chains": { + "drums": [ + {"device": "Drum Buss", "parameters": {"Drive": 1.5, "Comp": 0.25}}, + ], + "bass": [ + {"device": "Saturator", "parameters": {"Drive": 2.0, "Dry/Wet": 0.12}}, + {"device": "Auto Filter", "parameters": {"Frequency": 100.0, "Resonance": 0.25}}, + ], + "music": [ + {"device": "Auto Filter", "parameters": {"Frequency": 10000.0, "Dry/Wet": 0.08}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.18, "Feedback": 0.6}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.15}}, + ], + "vocal": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.22, "Feedback": 0.5}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.2}}, + ], + "fx": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.4}}, + ], + }, + "automation_defaults": { + "intro": {"filter_cutoff_mult": 0.7, "reverb_wet_mult": 1.0, "delay_wet_mult": 1.0}, + "build": {"filter_cutoff_mult": 0.85, "reverb_wet_mult": 1.2, "delay_wet_mult": 1.15}, + "drop": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 0.8, "delay_wet_mult": 0.7}, + "break": {"filter_cutoff_mult": 0.6, "reverb_wet_mult": 1.3, "delay_wet_mult": 0.95}, + "outro": {"filter_cutoff_mult": 0.8, "reverb_wet_mult": 1.1, "delay_wet_mult": 1.05}, + }, + "loudness_target": { + "integrated_lufs": -6.0, + "true_peak_db": -0.3, + "lra": 5.0, + }, + }, + "melodic-techno": { + "bus_config": { + "drums": {"gain_db": 0.0, "pan": 0.0, "color": 10}, + "bass": {"gain_db": -0.5, "pan": 0.0, "color": 30}, + "music": {"gain_db": -1.5, "pan": 0.0, "color": 45}, + "vocal": {"gain_db": -2.5, "pan": 0.0, "color": 60}, + "fx": {"gain_db": -3.0, "pan": 0.0, "color": 75}, + }, + "returns": { + "heat": {"type": "Saturator", "gain_db": 0.5, "dry_wet": 1.0}, + "glue": {"type": "Glue Compressor", "gain_db": 0.0, "dry_wet": 0.35}, + "space": {"type": "Hybrid Reverb", "gain_db": -2.5, "dry_wet": 0.55}, + "echo": {"type": "Echo", "gain_db": -6.0, "dry_wet": 0.45}, + }, + "device_chains": { + "drums": [ + {"device": "Drum Buss", "parameters": {"Drive": 2.8, "Comp": 0.45}}, + {"device": "Saturator", "parameters": {"Drive": 2.5, "Dry/Wet": 0.18}}, + ], + "bass": [ + {"device": "Saturator", "parameters": {"Drive": 3.5, "Dry/Wet": 0.22}}, + {"device": "Auto Filter", "parameters": {"Frequency": 130.0, "Resonance": 0.35}}, + ], + "music": [ + {"device": "Auto Filter", "parameters": {"Frequency": 7500.0, "Dry/Wet": 0.12}}, + {"device": "Echo", "parameters": {"Dry/Wet": 0.16, "Feedback": 0.55}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.1}}, + ], + "vocal": [ + {"device": "Echo", "parameters": {"Dry/Wet": 0.22, "Feedback": 0.45}}, + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.15}}, + ], + "fx": [ + {"device": "Hybrid Reverb", "parameters": {"Dry/Wet": 0.38}}, + {"device": "Saturator", "parameters": {"Drive": 1.5, "Dry/Wet": 0.1}}, + ], + }, + "automation_defaults": { + "intro": {"filter_cutoff_mult": 0.55, "reverb_wet_mult": 1.2, "delay_wet_mult": 1.0}, + "build": {"filter_cutoff_mult": 0.9, "reverb_wet_mult": 1.35, "delay_wet_mult": 1.2}, + "drop": {"filter_cutoff_mult": 1.0, "reverb_wet_mult": 0.55, "delay_wet_mult": 0.5}, + "break": {"filter_cutoff_mult": 0.45, "reverb_wet_mult": 1.5, "delay_wet_mult": 1.1}, + "outro": {"filter_cutoff_mult": 0.65, "reverb_wet_mult": 1.3, "delay_wet_mult": 1.05}, + }, + "loudness_target": { + "integrated_lufs": -7.5, + "true_peak_db": -0.8, + "lra": 6.0, + }, + }, +} + + +def _windows_short_path(path: Union[str, Path]) -> str: + """Convierte una ruta a su forma corta de Windows para evitar espacios en mensajes UDP.""" + normalized = str(path) + if os.name != "nt": + return normalized + + get_short_path = getattr(ctypes.windll.kernel32, "GetShortPathNameW", None) + if get_short_path is None: + return normalized + + output_buffer_size = 4096 + output_buffer = ctypes.create_unicode_buffer(output_buffer_size) + result = get_short_path(normalized, output_buffer, output_buffer_size) + if result == 0: + return normalized + return output_buffer.value or normalized + + +def _udp_safe_path(path: Union[str, Path]) -> str: + """Normaliza rutas para mensajes simples de UDP hacia Max for Live.""" + return _windows_short_path(path).replace("\\", "/") + + +# ============================================================================ +# SECTION VARIATION - Feature 3.3 +# ============================================================================ + +# Roles que pueden variar según la sección +SECTION_VARIATION_ROLES = { + 'kick', 'clap', 'hat', 'perc', 'ride', 'top_loop', + 'sub_bass', 'bass', + 'chords', 'pad', 'pluck', 'arp', 'lead', 'counter', + 'vocal', 'vocal_chop', +} + + +def _apply_section_variation_to_plan(plan: Dict[str, Any], + sections: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Aplica variación por sección al plan de referencia. + + Para cada rol elegible, filtra/reordena samples según la sección. + """ + varied_plan = plan.copy() + + # Obtener layers del plan + layers = plan.get('layers', []) + + for section in sections: + section_kind = section.get('kind', 'unknown') + section_name = section.get('name', '') + section_start = section.get('start', 0) + + # Para cada layer variante + for layer in layers: + role = layer.get('role', '') + + if role not in SECTION_VARIATION_ROLES: + continue + + # Obtener variante para esta sección + variant = _get_section_variant_for_role(role, section_kind, section_name) + + if variant != 'standard': + # Marcar layer para variación en esta sección + if 'section_variants' not in layer: + layer['section_variants'] = {} + + layer['section_variants'][section_start] = { + 'variant': variant, + 'section_kind': section_kind, + 'section_name': section_name + } + + logger.debug("SECTION_VARIATION: role '%s' will use variant '%s' in section '%s' (start=%.1f)", + role, variant, section_name, section_start) + + varied_plan['layers'] = layers + return varied_plan + + +def _get_section_variant_for_role(role: str, section_kind: str, section_name: str) -> str: + """Helper para obtener variante de sección para un rol.""" + # Mapeo simple de sección a variante + kind_lower = section_kind.lower() + name_lower = section_name.lower() + + # Detectar por nombre + if 'minimal' in name_lower or 'atmos' in name_lower: + return 'minimal' + if 'peak' in name_lower or 'main' in name_lower: + return 'full' + + # Defaults por tipo + section_variants = { + 'intro': 'sparse', + 'verse': 'standard', + 'build': 'building', + 'drop': 'full', + 'break': 'sparse', + 'outro': 'fading' + } + + return section_variants.get(kind_lower, 'standard') + + +def _filter_samples_by_variant(samples: List, variant: str) -> List: + """Filtra samples según variante de sección.""" + if variant == 'standard' or not samples: + return samples + + filtered = [] + for sample in samples: + name_lower = getattr(sample, 'name', '').lower() + + # Variant sparse: buscar keywords sutiles + if variant == 'sparse' or variant == 'minimal': + if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'simple', 'minimal']): + filtered.insert(0, sample) + elif any(kw in name_lower for kw in ['heavy', 'full', 'busy', 'big']): + continue + else: + filtered.append(sample) + + # Variant full: buscar keywords ricos + elif variant in ['full', 'peak', 'building']: + if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak']): + filtered.insert(0, sample) + elif any(kw in name_lower for kw in ['minimal', 'subtle']): + continue + else: + filtered.append(sample) + + else: + filtered.append(sample) + + return filtered if filtered else samples + + +# ============================================================================ +# M4L DEVICE MANAGEMENT - Hardened Loading with Fallback +# ============================================================================ + +M4L_LOAD_TIMEOUT = 5.0 # seconds to wait for device load +M4L_UDP_TIMEOUT = 2.0 # seconds for UDP command timeout + + +def verify_m4l_device_files_exist() -> Dict[str, Any]: + """ + Verifica que los archivos de dispositivo M4L existen. + Retorna dict con estado de cada archivo y si el sistema M4L es utilizable. + """ + result = { + "sampler_exists": PROJECT_M4L_SAMPLER_DEVICE.exists() if PROJECT_M4L_SAMPLER_DEVICE else False, + "sampler_path": str(PROJECT_M4L_SAMPLER_DEVICE) if PROJECT_M4L_SAMPLER_DEVICE else None, + "engine_exists": False, + "engine_path": None, + "usable": False, + "missing": [], + } + + if not result["sampler_exists"]: + result["missing"].append("AbletonMCP_SamplerPro.amxd") + + engine_path = PROJECT_M4L_DIR / "AbletonMCP_Engine.amxd" if PROJECT_M4L_DIR else None + if engine_path: + result["engine_exists"] = engine_path.exists() + result["engine_path"] = str(engine_path) + if not result["engine_exists"]: + result["missing"].append("AbletonMCP_Engine.amxd") + + result["usable"] = result["sampler_exists"] + return result + + +def ensure_m4l_sampler_device_installed() -> Optional[Path]: + """ + Copia el device M4L a ubicaciones que Live indexa como audio effects. + Retorna la ruta instalada o None si falla (en lugar de lanzar excepcion). + """ + try: + if not PROJECT_M4L_SAMPLER_DEVICE.exists(): + logger.warning(f"Device M4L no encontrado: {PROJECT_M4L_SAMPLER_DEVICE}") + return None + + install_targets = [ + INSTALLED_M4L_SAMPLER_DEVICE, + FACTORY_M4L_SAMPLER_DEVICE, + ] + + installed_path = None + for target in install_targets: + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(PROJECT_M4L_SAMPLER_DEVICE, target) + if installed_path is None: + installed_path = target + logger.debug(f"Device M4L copiado a: {target}") + except PermissionError as pe: + logger.debug(f"Sin permisos para copiar a {target}: {pe}") + except OSError as ose: + logger.debug(f"Error copiando a {target}: {ose}") + + return installed_path or INSTALLED_M4L_SAMPLER_DEVICE + + except Exception as e: + logger.error(f"Error instalando device M4L: {e}") + return None + + +def send_m4l_sampler_command(command: str, *parts: Union[str, int, float]) -> bool: + """ + Envia un comando simple por UDP al device SamplerPro. + Retorna True si el envio fue exitoso, False si fallo. + """ + try: + payload_parts = [str(command)] + payload_parts.extend(str(part) for part in parts if part not in (None, "")) + payload = " ".join(payload_parts).encode("utf-8") + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(M4L_UDP_TIMEOUT) + try: + sock.sendto(payload, (HOST, M4L_SAMPLER_PORT)) + return True + except socket.timeout: + logger.debug(f"Timeout enviando comando M4L: {command}") + return False + except OSError as ose: + logger.debug(f"Error de socket enviando comando M4L: {ose}") + return False + finally: + sock.close() + except Exception as e: + logger.debug(f"Error enviando comando M4L '{command}': {e}") + return False + + +def try_load_m4l_device_on_track( + ableton, + track_index: int, + device_name: str = M4L_DEVICE_NAME, + verify_load: bool = True +) -> Dict[str, Any]: + """ + Intenta cargar un dispositivo M4L en un track con verificacion. + Retorna dict con: success, device_name, error, verified. + """ + result = { + "success": False, + "device_name": device_name, + "error": None, + "verified": False, + } + + verify_result = verify_m4l_device_files_exist() + if not verify_result["usable"]: + result["error"] = f"Archivo M4L no encontrado: {', '.join(verify_result['missing'])}" + return result + + installed_path = ensure_m4l_sampler_device_installed() + if installed_path is None: + result["error"] = "No se pudo instalar el device M4L en User Library" + return result + + try: + load_response = ableton.send_command("load_device", { + "track_index": track_index, + "device_name": device_name, + }) + + if _is_error_response(load_response): + result["error"] = f"Error cargando device: {load_response.get('message')}" + return result + + result["success"] = True + + if verify_load: + time.sleep(0.5) + try: + info_response = ableton.send_command("get_track_info", { + "track_index": track_index + }) + if info_response.get("status") == "success": + devices = info_response.get("result", {}).get("devices", []) + device_names = [d.get("name", "").lower() for d in devices] + if any(device_name.lower() in name for name in device_names): + result["verified"] = True + else: + logger.debug(f"Device {device_name} no encontrado en track. Devices: {device_names}") + except Exception as ve: + logger.debug(f"No se pudo verificar carga del device: {ve}") + + return result + + except Exception as e: + result["error"] = f"Excepcion cargando device M4L: {e}" + return result + +def _select_hybrid_sample_paths(genre: str, key: str = "", bpm: float = 0) -> Dict[str, str]: + """Selecciona rutas concretas de samples para el device híbrido M4L.""" + selector = get_sample_selector() + if not selector: + raise RuntimeError("Selector de samples no disponible") + + group = selector.select_for_genre(genre, key or None, bpm if bpm > 0 else None) + drum_kit = group.drums + + sample_paths = { + "kick": drum_kit.kick.path if drum_kit and drum_kit.kick else "", + "snare": "", + "hat": "", + "bass": "", + } + + if drum_kit: + sample_paths["snare"] = ( + drum_kit.snare.path if drum_kit.snare + else drum_kit.clap.path if drum_kit.clap + else "" + ) + sample_paths["hat"] = ( + drum_kit.hat_closed.path if drum_kit.hat_closed + else drum_kit.hat_open.path if drum_kit.hat_open + else "" + ) + + if group.bass: + sample_paths["bass"] = group.bass[0].path + + missing = [name for name, value in sample_paths.items() if not value] + if missing: + raise RuntimeError(f"Faltan samples para el modo híbrido: {', '.join(missing)}") + + return sample_paths + + +def _find_library_file(*patterns: str, rng: Optional[random.Random] = None) -> str: + """Busca un archivo de la librería usando VectorManager (Búsqueda semántica inteligente) con fallback a glob.""" + library_dir = Path(SAMPLES_DIR) + if not library_dir.exists(): + return "" + + local_rng = rng or random + + # Patrones que indican canciones completas (no samples) + FULL_SONG_INDICATORS = [ + "extended mix", "original mix", "radio edit", "club mix", "remix", + "feat.", "ft.", "pres.", " vs ", " - ", # Artistas con guiones + ] + + def is_likely_full_song(filepath: str) -> bool: + """Detecta si un archivo es probablemente una canción completa.""" + name_lower = Path(filepath).name.lower() + # Excluir archivos muy largos (>50 chars suelen ser canciones) + if len(name_lower) > 50: + return True + # Excluir por palabras clave de canciones + for indicator in FULL_SONG_INDICATORS: + if indicator in name_lower: + return True + return False + + # Intento de búsqueda semántica con VectorManager + try: + from vector_manager import VectorManager + vm = VectorManager(str(library_dir)) + + # Limpiar los patrones para convertirlos en un prompt semántico + query = " ".join([p.replace('*', '').replace('.wav', '').strip() for p in patterns]) + if query: + results = vm.semantic_search(query, limit=10) # Buscar más para filtrar + if results: + # Filtrar resultados recientes Y canciones completas + valid_results = [ + r for r in results + if r['path'].lower() not in _RECENT_LIBRARY_MATCHES + and not is_likely_full_song(r['path']) + ] + pool = valid_results or results + if pool: + selected = pool[local_rng.randrange(len(pool))]['path'] + _RECENT_LIBRARY_MATCHES.append(selected.lower()) + return selected + except Exception as e: + import logging + logging.getLogger("server").warning(f"Semantic search failed: {e}. Falling back to glob.") + + # Fallback original + matches: List[Path] = [] + seen = set() + for pattern in patterns: + for match in sorted(library_dir.glob(pattern)): + if not match.is_file(): + continue + key = str(match.resolve()).lower() + if key in seen: + continue + # Filtrar canciones completas + if is_likely_full_song(str(match)): + continue + seen.add(key) + matches.append(match) + + if not matches: + return "" + + prioritized = [match for match in matches if str(match.resolve()).lower() not in _RECENT_LIBRARY_MATCHES] + pool = prioritized or matches + selected = pool[local_rng.randrange(len(pool))] + _RECENT_LIBRARY_MATCHES.append(str(selected.resolve()).lower()) + return str(selected) + + +def _build_audio_fallback_sample_paths(genre: str, key: str = "", bpm: float = 0) -> Dict[str, str]: + """Obtiene los samples necesarios para el fallback de audio directo.""" + variant_seed = None + try: + generator = get_song_generator() + current_profile = getattr(generator, "_current_generation_profile", {}) or {} + variant_seed = current_profile.get("seed") + except Exception: + variant_seed = None + rng = random.Random(int(variant_seed)) if variant_seed is not None else random.Random() + + sample_paths = _select_hybrid_sample_paths(genre, key, bpm) + sample_paths["perc_loop"] = _find_library_file("*Percussion Loop*.wav", "*Perc Loop*.wav", rng=rng) + sample_paths["vocal_loop"] = _find_library_file("*Vocal Loop*.wav", "*Vox*.wav", rng=rng) + sample_paths["perc_alt"] = _find_library_file("*Percussion Loop*.wav", "*Perc Loop*.wav", "*Drum Loop*Perc*.wav", rng=rng) + sample_paths["top_loop"] = _find_library_file("*Top Loop*.wav", "*Drum Loop*Full*.wav", "*Full Mix*.wav", rng=rng) + sample_paths["synth_loop"] = _find_library_file("*Synth_Loop*.wav", "*Synth Loop*.wav", "*Music Loop*.wav", rng=rng) + sample_paths["synth_peak"] = _find_library_file("*Lead Loop*.wav", "*Synth_Loop*.wav", "*Hook*.wav", rng=rng) + sample_paths["vocal_build"] = _find_library_file("*Vocal Loop*.wav", "*Vox*.wav", "*Chant*.wav", rng=rng) + sample_paths["vocal_peak"] = _find_library_file("*Vocal Loop*.wav", "*Vox*.wav", "*Hook Vocal*.wav", rng=rng) + sample_paths["crash_fx"] = _find_library_file("*Crash*.wav", "*Impact*.wav", rng=rng) + sample_paths["fill_fx"] = _find_library_file("*Fill*.wav", "*Transition*.wav", rng=rng) + sample_paths["snare_roll"] = _find_library_file("*Snareroll*.wav", "*Snare Roll*.wav", rng=rng) + sample_paths["atmos_fx"] = _find_library_file("*Atmos*.wav", "*Drone*.wav", "*Texture*.wav", "*Ambience*.wav", rng=rng) + sample_paths["vocal_shot"] = _find_library_file("*Vocal One Shot*.wav", "*Vox One Shot*.wav", "*Vocal Shot*.wav", rng=rng) + return sample_paths + + +def _iter_audio_fallback_sections(total_beats: int, config: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + sections = list((config or {}).get("sections", []) or []) + timeline: List[Dict[str, Any]] = [] + cursor = 0.0 + + for index, section in enumerate(sections): + if not isinstance(section, dict): + continue + beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) + if beats <= 0: + continue + start = cursor + end = min(float(total_beats), start + beats) + if end <= start: + continue + timeline.append({ + "index": index, + "kind": str(section.get("kind", "drop") or "drop").lower(), + "name": str(section.get("name", "") or ""), + "start": start, + "end": end, + }) + cursor = end + if cursor >= float(total_beats): + break + + if timeline: + return timeline + + generic = [ + ("intro", 0.0, min(float(total_beats), 16.0)), + ("build", min(float(total_beats), 16.0), min(float(total_beats), 32.0)), + ("drop", min(float(total_beats), 32.0), min(float(total_beats), 48.0)), + ("break", min(float(total_beats), 48.0), min(float(total_beats), 64.0)), + ("drop", min(float(total_beats), 64.0), float(total_beats)), + ] + for index, (kind, start, end) in enumerate(generic): + if end > start: + timeline.append({"index": index, "kind": kind, "name": kind.title(), "start": start, "end": end}) + return timeline + + +def _build_positions_for_range(start: float, end: float, step: float, offset: float = 0.0) -> List[float]: + positions: List[float] = [] + if step <= 0 or end <= start: + return positions + position = start + offset + while position < end - 0.05: + positions.append(round(position, 3)) + position += step + return positions + + +def _build_audio_pattern_positions(total_beats: int = 16, config: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]: + """Patrones básicos para el fallback de audio en arrangement.""" + clap_positions = [beat for beat in range(total_beats) if beat % 4 in (1, 3)] + loop_positions = [float(beat) for beat in range(0, max(total_beats, 16), 16)] + vocal_positions = [float(beat) for beat in range(8, max(total_beats, 16), 16)] + positions = { + "kick": [float(beat) for beat in range(total_beats)], + "snare": [float(beat) for beat in clap_positions], + "hat": [round(0.5 + step * 0.5, 3) for step in range(total_beats * 2)], + "bass": loop_positions or [0.0], + "perc_loop": loop_positions or [0.0], + "vocal_loop": vocal_positions or [8.0], + "perc_alt": [], + "top_loop": [], + "synth_loop": [], + "synth_peak": [], + "vocal_build": [], + "vocal_peak": [], + "crash_fx": [], + "fill_fx": [], + "snare_roll": [], + "atmos_fx": [], + "vocal_shot": [], + } + for section in _iter_audio_fallback_sections(total_beats, config): + start = float(section["start"]) + end = float(section["end"]) + kind = str(section["kind"]).lower() + section_length = max(0.0, end - start) + + if kind in {"intro", "break", "outro"}: + positions["atmos_fx"].append(round(start, 3)) + + if kind in {"build", "drop"}: + positions["top_loop"].extend(_build_positions_for_range(start, end, 16.0)) + positions["synth_loop"].append(round(start, 3)) + positions["perc_alt"].extend(_build_positions_for_range(start, end, 8.0, 4.0)) + + if kind == "build": + positions["vocal_build"].append(round(max(start, end - min(8.0, section_length)), 3)) + positions["snare_roll"].append(round(max(start, end - min(4.0, section_length)), 3)) + positions["fill_fx"].append(round(max(start, end - 1.0), 3)) + elif kind == "drop": + positions["crash_fx"].append(round(start, 3)) + positions["synth_peak"].extend(_build_positions_for_range(start, end, 16.0)) + positions["vocal_peak"].append(round(start, 3)) + positions["vocal_shot"].extend(_build_positions_for_range(start, end, 8.0, 1.5)) + if section_length >= 16.0: + positions["fill_fx"].append(round(end - 1.0, 3)) + elif kind == "break": + positions["vocal_loop"].append(round(start + min(8.0, max(0.0, section_length / 2.0)), 3)) + positions["fill_fx"].append(round(max(start, end - 1.0), 3)) + + for key, values in positions.items(): + positions[key] = sorted({ + round(float(value), 3) + for value in values + if 0.0 <= float(value) < float(total_beats) + }) + return positions + +def _build_reference_audio_plan(config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not isinstance(config, dict): + return None + + reference_track = config.get("reference_track") + reference_path = "" + if isinstance(reference_track, dict): + reference_path = str(reference_track.get("path", "") or "") + if not reference_path: + return None + + listener = get_reference_listener() + if listener is None: + return None + + sections = config.get("sections", []) or [] + bpm = float(config.get("bpm", 0.0) or 0.0) + key = str(config.get("key", "") or "") + variant_seed = config.get("variant_seed", None) + + try: + plan = listener.build_arrangement_plan(reference_path, sections, bpm, key, variant_seed=variant_seed) + except Exception as exc: + logger.error("Error construyendo plan de referencia desde %s: %s", reference_path, exc) + return None + + if not isinstance(plan, dict): + logger.warning("Plan de referencia invalido para %s", reference_path) + return None + + config["reference_audio_plan"] = plan + + reference = plan.get("reference", {}) + ref_tempo = float(reference.get("tempo", 0.0) or 0.0) + ref_key = str(reference.get("key", "") or "") + if ref_tempo > 0: + config["bpm"] = round(ref_tempo, 3) + if ref_key: + config["key"] = ref_key + config["scale"] = "minor" if "m" in ref_key.lower() else "major" + + resampler = get_audio_resampler() + if resampler is not None: + try: + derived_layers = resampler.build_transition_layers( + plan, + sections, + float(config.get("bpm", bpm) or bpm or ref_tempo or 0.0), + variant_seed=variant_seed, + ) + if derived_layers: + plan.setdefault("layers", []).extend(derived_layers) + plan["derived_layers"] = derived_layers + logger.info( + "Derived %d transition layers: %s", + len(derived_layers), + [layer.get("name", "unnamed") for layer in derived_layers] + ) + for layer in derived_layers: + logger.debug( + " - %s: positions=%s, volume=%.2f, source=%s", + layer.get("name", "unnamed"), + layer.get("positions", []), + float(layer.get("volume", 0.0)), + layer.get("source", "unknown") + ) + except Exception as exc: + logger.warning("No se pudieron derivar transiciones internas: %s", exc, exc_info=True) + + # Aplicar variación por sección para roles elegibles + if sections: + plan = _apply_section_variation_to_plan(plan, sections) + + total_layers = len(plan.get("layers", [])) + derived_count = len(derived_layers) if derived_layers else 0 + if total_layers > 0: + logger.info( + "Reference audio plan listo: %d capas totales (%d derivadas + %d base)", + total_layers, derived_count, total_layers - derived_count + ) + + return plan + + +def _mute_tracks_for_audio_layers(ableton: "AbletonConnection", layer_names: List[str]) -> int: + muted = 0 + target_names = set() + for layer_name in layer_names: + template_name = _match_audio_track_template(layer_name, REFERENCE_AUDIO_MUTE_MAP) + if template_name: + target_names.update(REFERENCE_AUDIO_MUTE_MAP.get(template_name, ())) + + if target_names: + response = ableton.send_command("get_tracks") + if not _is_error_response(response): + result = response.get("result", []) + if isinstance(result, dict): + tracks = result.get("tracks", []) + elif isinstance(result, list): + tracks = result + else: + tracks = [] + + for track in tracks: + track_name = str(track.get("name", "") or "").strip().upper() + if track_name not in target_names: + continue + try: + ableton.send_command("set_track_mute", { + "track_index": int(track.get("index", -1)), + "mute": True, + }) + muted += 1 + except Exception: + pass + + if muted == 0: + for track_index in range(5): + try: + ableton.send_command("set_track_mute", {"track_index": track_index, "mute": True}) + muted += 1 + except Exception: + pass + + return muted + + +def _clamp_float(value: float, minimum: float, maximum: float) -> float: + return max(minimum, min(maximum, float(value))) + + +def _format_reference_audio_layer_result(materialized: Dict[str, Any]) -> str: + parts = [ + f"Audio reference fallback listo ({materialized.get('reference_name', 'referencia')}, " + f"{materialized.get('reference_device', 'numpy')}): " + + ", ".join(materialized.get("created_tracks", [])) + ] + if materialized.get("audio_mix_reports"): + parts.append(" | Mix: " + " / ".join(materialized.get("audio_mix_reports", []))) + parts.append(f" | MIDI silenciados: {int(materialized.get('muted_tracks', 0))}") + layer_errors = materialized.get("layer_errors", []) + if layer_errors: + parts.append(f" | Errores: {len(layer_errors)} layers fallaron") + return "".join(parts) + + +def _materialize_reference_audio_layers( + ableton: "AbletonConnection", + reference_audio_plan: Dict[str, Any], + total_beats: int, + return_mapping: Dict[str, int], + mute_duplicates: bool = True, + finalize_transport: bool = True, +) -> Dict[str, Any]: + created_tracks: List[str] = [] + audio_mix_reports: List[str] = [] + audio_track_indices: Dict[str, int] = {} + layer_metadata: Dict[str, Dict[str, Any]] = {} + layer_names: List[str] = [] + layer_errors: List[str] = [] + + all_layers = list(reference_audio_plan.get("layers", [])) + derived_layer_names = set() + derived_layers = reference_audio_plan.get("derived_layers", []) + if derived_layers: + derived_layer_names = {layer.get("name") for layer in derived_layers if isinstance(layer, dict)} + all_layers.extend(derived_layers) + + logger.info( + "Materializing %d audio layers (%d derived, %d base)", + len(all_layers), len(derived_layer_names), len(all_layers) - len(derived_layer_names) + ) + + for layer_index, layer in enumerate(all_layers): + if not isinstance(layer, dict): + continue + + sample_path = str(layer.get("file_path", "") or "") + positions = list(layer.get("positions", []) or []) + track_name = str(layer.get("name", "AUDIO LAYER") or "AUDIO LAYER") + if not sample_path or not positions: + logger.debug("Skipping layer %d (%s): missing path or positions", layer_index, track_name) + continue + + is_derived = track_name in derived_layer_names + layer_type = "DERIVED" if is_derived else "BASE" + role = layer.get('role', '') + + # Check si tiene variantes por sección + section_variants = layer.get('section_variants', {}) + + if section_variants: + logger.debug("MATERIALIZE: role '%s' has %d section variants", role, len(section_variants)) + + # Procesar cada variante de sección + for section_start, variant_info in section_variants.items(): + # Usar samples filtrados según variante + variant_samples = _filter_samples_by_variant( + layer.get('samples', []), + variant_info.get('variant', 'standard') + ) + + if variant_samples != layer.get('samples', []): + logger.debug("VARIANT_MATERIALIZATION: role '%s' using variant samples for section starting at %.1f", + role, section_start) + # Usar variant_samples para esta sección + # Nota: La lógica de filtrado específica por sección se implementaría aquí + # si los samples tuvieran suficiente metadato + + logger.debug( + "[%s] Layer %d: %s, positions=%s, volume=%.2f", + layer_type, layer_index, track_name, positions, float(layer.get("volume", 0.7)) + ) + + try: + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + raise RuntimeError(create_response.get("message", f"No se pudo crear {track_name}")) + + track_index = create_response.get("result", {}).get("index") + if track_index is None: + raise RuntimeError(f"Ableton no devolvio el indice para {track_name}") + + base_volume = float(layer.get("volume", 0.7)) + ableton.send_command("set_track_name", {"track_index": track_index, "name": track_name}) + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": int(layer.get("color", 20)), + }) + ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": _linear_to_live_slider(base_volume), + }) + + pattern_response = ableton.send_command("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": sample_path, + "positions": positions, + "name": track_name, + }) + if _is_error_response(pattern_response): + raise RuntimeError(pattern_response.get("message", f"No se pudo crear audio para {track_name}")) + + mix_result = _apply_audio_track_mix( + ableton, + track_index, + track_name, + base_volume, + return_mapping, + ) + audio_mix_reports.append( + f"{track_name}: pan {mix_result['pan']:+.2f}, sends {mix_result['sends']}, fx {mix_result['fx']}" + ) + layer_names.append(track_name) + created_tracks.append(f"{track_name}: {Path(sample_path).name}") + audio_track_indices[track_name] = int(track_index) + layer_metadata[track_name] = { + "track_index": int(track_index), + "volume": base_volume, + "positions": positions, + "color": int(layer.get("color", 20)), + } + logger.debug( + "[%s] Created track %d: %s (pan=%.2f, sends=%d, fx=%d)", + layer_type, track_index, track_name, mix_result['pan'], mix_result['sends'], mix_result['fx'] + ) + except Exception as layer_exc: + error_msg = f"Layer {layer_index} ({track_name}) fallo: {layer_exc}" + logger.error(error_msg) + layer_errors.append(error_msg) + continue + + if not created_tracks: + error_summary = "; ".join(layer_errors) if layer_errors else "Sin layers validos" + raise RuntimeError(f"No se pudieron crear capas de audio guiadas por referencia: {error_summary}") + + derived_created = sum(1 for name in layer_names if name in derived_layer_names) + base_created = len(layer_names) - derived_created + logger.info( + "Materialization complete: %d tracks created (%d derived, %d base), %d errors", + len(created_tracks), derived_created, base_created, len(layer_errors) + ) + + muted_tracks = _mute_tracks_for_audio_layers(ableton, layer_names) if mute_duplicates else 0 + if finalize_transport: + ableton.send_command("loop_selection", {"start": 0, "length": float(total_beats), "enable": False}) + ableton.send_command("jump_to", {"time": 0}) + + reference = reference_audio_plan.get("reference", {}) + return { + "created_tracks": created_tracks, + "audio_mix_reports": audio_mix_reports, + "audio_track_indices": audio_track_indices, + "layer_metadata": layer_metadata, + "layer_names": layer_names, + "muted_tracks": muted_tracks, + "reference_name": reference.get("file_name", "referencia"), + "reference_device": reference.get("device", "numpy"), + "layer_errors": layer_errors, + } + + +def _layer_has_activity_in_section(layer_data: Dict[str, Any], start: float, end: float) -> bool: + for position in layer_data.get("positions", []) or []: + try: + position_value = float(position) + except Exception: + continue + if start <= position_value < end: + return True + return False + + +def _reference_audio_section_factor(track_name: str, section_kind: str, section_name: str) -> float: + normalized = str(track_name or "").strip().upper() + kind = str(section_kind or "drop").lower() + is_peak = "peak" in str(section_name or "").lower() + + if normalized in {"AUDIO KICK", "AUDIO CLAP", "AUDIO HAT", "AUDIO BASS LOOP", "AUDIO PERC MAIN", "AUDIO PERC ALT"}: + factors = {"intro": 0.82, "build": 0.92, "drop": 1.0, "break": 0.74, "outro": 0.78} + elif normalized == "AUDIO TOP LOOP": + factors = {"intro": 0.38, "build": 0.74, "drop": 1.0, "break": 0.5, "outro": 0.44} + elif normalized == "AUDIO SYNTH LOOP": + factors = {"intro": 0.0, "build": 0.64, "drop": 0.9, "break": 0.34, "outro": 0.24} + elif normalized == "AUDIO SYNTH PEAK": + factors = {"intro": 0.0, "build": 0.34, "drop": 0.86, "break": 0.0, "outro": 0.0} + elif normalized == "AUDIO VOCAL LOOP": + factors = {"intro": 0.0, "build": 0.58, "drop": 0.82, "break": 0.3, "outro": 0.0} + elif normalized == "AUDIO VOCAL BUILD": + factors = {"intro": 0.0, "build": 1.0, "drop": 0.42, "break": 0.38, "outro": 0.0} + elif normalized == "AUDIO VOCAL PEAK": + factors = {"intro": 0.0, "build": 0.26, "drop": 0.92, "break": 0.0, "outro": 0.0} + elif normalized in {"AUDIO CRASH FX", "AUDIO TRANSITION FILL", "AUDIO SNARE ROLL"}: + factors = {"intro": 0.0, "build": 1.0, "drop": 0.9, "break": 0.86, "outro": 0.2} + elif normalized == "AUDIO ATMOS": + factors = {"intro": 1.0, "build": 0.68, "drop": 0.46, "break": 0.94, "outro": 0.86} + elif normalized == "AUDIO VOCAL SHOT": + factors = {"intro": 0.0, "build": 0.56, "drop": 0.92, "break": 0.0, "outro": 0.0} + elif normalized == "AUDIO RESAMPLE REVERSE FX": + factors = {"intro": 0.0, "build": 1.0, "drop": 0.88, "break": 0.78, "outro": 0.32} + elif normalized == "AUDIO RESAMPLE RISER": + factors = {"intro": 0.0, "build": 1.0, "drop": 0.62, "break": 0.0, "outro": 0.0} + elif normalized == "AUDIO RESAMPLE DOWNLIFTER": + factors = {"intro": 0.0, "build": 0.22, "drop": 0.42, "break": 1.0, "outro": 0.88} + elif normalized == "AUDIO RESAMPLE STUTTER": + factors = {"intro": 0.0, "build": 0.96, "drop": 0.76, "break": 0.28, "outro": 0.0} + else: + factors = {"intro": 0.7, "build": 0.82, "drop": 1.0, "break": 0.62, "outro": 0.58} + + factor = float(factors.get(kind, 0.78)) + if is_peak and normalized in {"AUDIO SYNTH PEAK", "AUDIO VOCAL PEAK", "AUDIO TOP LOOP", "AUDIO CRASH FX"}: + factor *= 1.08 + return factor + + +def _reference_audio_send_scales(track_name: str, section_kind: str, section_name: str) -> Dict[str, float]: + normalized = str(track_name or "").strip().upper() + kind = str(section_kind or "drop").lower() + name = str(section_name or "").lower() + + scales = { + "space": 1.18 if kind == "break" else 1.06 if kind == "intro" else 0.94 if kind == "drop" else 1.0, + "echo": 1.22 if kind == "build" else 1.12 if "peak" in name else 0.9 if kind == "outro" else 1.0, + "heat": 1.14 if kind == "drop" else 0.88 if kind in {"intro", "break"} else 1.0, + "glue": 1.08 if kind == "drop" else 0.94 if kind == "intro" else 1.0, + "pan": 1.16 if kind == "drop" else 0.86 if kind == "break" else 1.0, + } + + if normalized in {"AUDIO CRASH FX", "AUDIO TRANSITION FILL", "AUDIO SNARE ROLL"}: + scales["space"] += 0.08 + scales["echo"] += 0.12 + if normalized in {"AUDIO RESAMPLE REVERSE FX", "AUDIO RESAMPLE RISER", "AUDIO RESAMPLE DOWNLIFTER"}: + scales["space"] += 0.16 + scales["echo"] += 0.14 + scales["heat"] += 0.06 if kind in {"build", "drop"} else 0.0 + if normalized == "AUDIO RESAMPLE STUTTER": + scales["echo"] += 0.2 + scales["space"] += 0.06 if kind == "break" else 0.08 if kind == "drop" else 0.04 + if normalized.startswith("AUDIO VOCAL"): + scales["echo"] += 0.08 if kind in {"build", "drop"} else 0.0 + scales["space"] += 0.04 if kind == "break" else 0.0 + if normalized == "AUDIO ATMOS": + scales["space"] += 0.1 + scales["pan"] *= 0.9 + + return scales + + +def _build_reference_audio_performance( + reference_audio_plan: Dict[str, Any], + sections: List[Dict[str, Any]], + materialized: Dict[str, Any], +) -> List[Dict[str, Any]]: + if not isinstance(reference_audio_plan, dict) or not sections: + return [] + + layer_metadata = materialized.get("layer_metadata", {}) + if not isinstance(layer_metadata, dict) or not layer_metadata: + return [] + + snapshots: List[Dict[str, Any]] = [] + arrangement_time = 0.0 + for scene_index, section in enumerate(sections): + beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) + start = arrangement_time + end = arrangement_time + max(1.0, beats) + arrangement_time = end + section_kind = str(section.get("kind", "drop")).lower() + section_name = str(section.get("name", "")).lower() + track_states: List[Dict[str, Any]] = [] + + for track_name, layer_data in layer_metadata.items(): + if not _layer_has_activity_in_section(layer_data, start, end): + continue + + base_volume = float(layer_data.get("volume", 0.7)) + base_profile = _resolve_audio_mix_profile(track_name, base_volume) + factor = _reference_audio_section_factor(track_name, section_kind, section_name) + scales = _reference_audio_send_scales(track_name, section_kind, section_name) + + track_state = { + "track_index": int(layer_data["track_index"]), + "volume": round(_clamp_float(base_volume * factor, 0.0, 1.0), 3), + "pan": round(_clamp_float(float(base_profile.get("pan", 0.0)) * scales["pan"], -1.0, 1.0), 3), + "sends": {}, + } + for send_name, send_value in dict(base_profile.get("sends", {})).items(): + send_scale = float(scales.get(str(send_name).lower(), 1.0)) + track_state["sends"][send_name] = round(_clamp_float(float(send_value) * send_scale, 0.0, 1.0), 3) + track_states.append(track_state) + + if track_states: + snapshots.append({ + "scene_index": int(section.get("index", scene_index)), + "track_states": track_states, + }) + + return snapshots + + +def _merge_performance_snapshots(base_snapshots: List[Dict[str, Any]], extra_snapshots: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + merged: Dict[int, Dict[str, Any]] = {} + for snapshot_list in (base_snapshots or [], extra_snapshots or []): + for item in snapshot_list: + if not isinstance(item, dict): + continue + scene_index = int(item.get("scene_index", len(merged))) + bucket = merged.setdefault(scene_index, {"scene_index": scene_index, "track_states": []}) + bucket["track_states"].extend([ + state for state in item.get("track_states", []) or [] + if isinstance(state, dict) + ]) + + return [merged[index] for index in sorted(merged)] + + +def _infer_m4l_pattern(genre: str, style: str = "") -> str: + genre_text = f"{genre} {style}".lower() + if "house" in genre_text: + return "house" + if "minimal" in genre_text: + return "minimal" + if "dnb" in genre_text or "drum-and-bass" in genre_text or "jungle" in genre_text: + return "breakbeat" + return "techno" + + +def setup_hybrid_m4l_sampler(genre: str, style: str = "", key: str = "", bpm: float = 0) -> str: + """ + Prepara el track hibrido M4L con manejo robusto de errores. + Usa try_load_m4l_device_on_track para carga verificada. + Retorna mensaje de exito o error descriptivo. + """ + # Verificar que los archivos M4L existen antes de proceder + verify_result = verify_m4l_device_files_exist() + if not verify_result["usable"]: + missing = ", ".join(verify_result["missing"]) + logger.warning(f"M4L no disponible: faltan archivos {missing}") + raise RuntimeError(f"M4L no disponible: archivos no encontrados ({missing})") + + try: + sample_paths = _select_hybrid_sample_paths(genre, key, bpm) + except Exception as sample_error: + logger.warning(f"Error seleccionando samples para M4L: {sample_error}") + raise RuntimeError(f"M4L no disponible: {sample_error}") from sample_error + + ableton = get_ableton_connection() + track_index = None + + # Crear track de audio + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + raise RuntimeError(f"M4L no disponible: {create_response.get('message', 'No se pudo crear track')}") + + track_index = create_response.get("result", {}).get("index") + if track_index is None: + raise RuntimeError("M4L no disponible: Ableton no devolvio indice del track") + + try: + # Configurar track + ableton.send_command("set_track_name", {"track_index": track_index, "name": HYBRID_DRUM_TRACK_NAME}) + ableton.send_command("set_track_color", {"track_index": track_index, "color": HYBRID_DRUM_TRACK_COLOR}) + ableton.send_command("set_track_volume", {"track_index": track_index, "volume": _linear_to_live_slider(0.78)}) + + # Cargar device M4L con verificacion + load_result = try_load_m4l_device_on_track(ableton, track_index, M4L_DEVICE_NAME, verify_load=True) + if not load_result.get("success"): + error_msg = load_result.get("error", "Error desconocido cargando device") + logger.warning(f"Fallo carga M4L: {error_msg}") + raise RuntimeError(error_msg) + + # Si el device no fue verificado, continuar con advertencia + if not load_result.get("verified"): + logger.warning("Device M4L cargado pero no verificado, continuando...") + + # Esperar a que M4L este listo + time.sleep(0.75) + + # Enviar comandos UDP con manejo de errores + commands_sent = 0 + if send_m4l_sampler_command("clear_song"): + commands_sent += 1 + if send_m4l_sampler_command("set_bpm", int(round(bpm)) if bpm else 128): + commands_sent += 1 + if send_m4l_sampler_command( + "load_drum_kit", + _udp_safe_path(sample_paths["kick"]), + _udp_safe_path(sample_paths["snare"]), + _udp_safe_path(sample_paths["hat"]), + _udp_safe_path(sample_paths["bass"]), + ): + commands_sent += 1 + if send_m4l_sampler_command("generate_pattern", _infer_m4l_pattern(genre, style)): + commands_sent += 1 + + # Si no se enviaron comandos UDP, el device probablemente no esta respondiendo + if commands_sent == 0: + logger.warning("Device M4L no responde a comandos UDP") + raise RuntimeError("Device M4L no responde a comandos UDP") + + logger.info(f"M4L listo: {commands_sent} comandos enviados") + return ( + f"Hibrido M4L listo en track {track_index}: " + f"{Path(sample_paths['kick']).name}, {Path(sample_paths['snare']).name}, " + f"{Path(sample_paths['hat']).name}, {Path(sample_paths['bass']).name}" + ) + + except Exception as e: + # Cleanup: eliminar track si falla + if track_index is not None: + try: + ableton.send_command("delete_track", {"track_index": track_index}) + except Exception: + pass + logger.error(f"Error en setup_hybrid_m4l_sampler: {e}") + raise + +def setup_audio_sample_fallback( + genre: str, + style: str = "", + key: str = "", + bpm: float = 0, + total_beats: int = 16, + config: Optional[Dict[str, Any]] = None, +) -> str: + """Crea un backing audible con clips de audio reales desde la libreria local.""" + ableton = get_ableton_connection() + created_tracks = [] + audio_mix_reports = [] + reference_audio_plan = None + return_mapping = _build_return_send_mapping(config) if isinstance(config, dict) else {} + if isinstance(config, dict): + reference_audio_plan = config.get("reference_audio_plan") + + if isinstance(reference_audio_plan, dict) and reference_audio_plan.get("layers"): + materialized = _materialize_reference_audio_layers( + ableton, + reference_audio_plan, + total_beats, + return_mapping, + mute_duplicates=True, + finalize_transport=True, + ) + return _format_reference_audio_layer_result(materialized) + + sample_paths = _build_audio_fallback_sample_paths(genre, key, bpm) + positions = _build_audio_pattern_positions(total_beats, config) + created_layer_names = [] + for track_name, sample_key, color, volume in AUDIO_FALLBACK_TRACK_SPECS: + sample_path = sample_paths.get(sample_key, "") + if not sample_path: + continue + + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + raise RuntimeError(create_response.get("message", f"No se pudo crear {track_name}")) + + track_index = create_response.get("result", {}).get("index") + if track_index is None: + raise RuntimeError(f"Ableton no devolvio el indice para {track_name}") + + ableton.send_command("set_track_name", {"track_index": track_index, "name": track_name}) + ableton.send_command("set_track_color", {"track_index": track_index, "color": color}) + ableton.send_command("set_track_volume", {"track_index": track_index, "volume": _linear_to_live_slider(volume)}) + + pattern_response = ableton.send_command("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": sample_path, + "positions": positions.get(sample_key, [0.0]), + "name": track_name, + }) + if _is_error_response(pattern_response): + raise RuntimeError(pattern_response.get("message", f"No se pudo crear audio para {track_name}")) + + mix_result = _apply_audio_track_mix(ableton, track_index, track_name, float(volume), return_mapping) + audio_mix_reports.append( + f"{track_name}: pan {mix_result['pan']:+.2f}, sends {mix_result['sends']}, fx {mix_result['fx']}" + ) + created_tracks.append(f"{track_name}: {Path(sample_path).name}") + created_layer_names.append(track_name) + + for optional_name, optional_key, color, volume in AUDIO_OPTIONAL_FALLBACK_TRACK_SPECS: + sample_path = sample_paths.get(optional_key, "") + if not sample_path: + continue + + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + continue + + track_index = create_response.get("result", {}).get("index") + if track_index is None: + continue + + ableton.send_command("set_track_name", {"track_index": track_index, "name": optional_name}) + ableton.send_command("set_track_color", {"track_index": track_index, "color": color}) + ableton.send_command("set_track_volume", {"track_index": track_index, "volume": _linear_to_live_slider(volume)}) + ableton.send_command("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": sample_path, + "positions": positions.get(optional_key, [0.0]), + "name": optional_name, + }) + mix_result = _apply_audio_track_mix(ableton, track_index, optional_name, float(volume), return_mapping) + audio_mix_reports.append( + f"{optional_name}: pan {mix_result['pan']:+.2f}, sends {mix_result['sends']}, fx {mix_result['fx']}" + ) + created_tracks.append(f"{optional_name}: {Path(sample_path).name}") + created_layer_names.append(optional_name) + + muted = _mute_tracks_for_audio_layers(ableton, created_layer_names) + + ableton.send_command("loop_selection", {"start": 0, "length": float(total_beats), "enable": False}) + ableton.send_command("jump_to", {"time": 0}) + + if not created_tracks: + raise RuntimeError("No se pudieron crear tracks de audio con la libreria local") + + return ( + "Audio fallback listo en arrangement: " + + ", ".join(created_tracks) + + (" | Mix: " + " / ".join(audio_mix_reports) if audio_mix_reports else "") + + f" | MIDI silenciados: {muted}" + ) + +def _sleep_until(target_time: float): + while True: + remaining = target_time - time.monotonic() + if remaining <= 0: + return + time.sleep(min(0.25, remaining)) + + +def _build_return_send_mapping(config: Dict[str, Any]) -> Dict[str, int]: + mapping: Dict[str, int] = {} + for index, item in enumerate(config.get("returns", []) or []): + if not isinstance(item, dict): + continue + send_key = str(item.get("send_key", item.get("name", ""))).strip().lower() + if send_key: + mapping[send_key] = index + return mapping + + +def _normalize_track_name(value: Any) -> str: + return " ".join(str(value or "").strip().upper().split()) + + +def _extract_tracks_payload(response: Dict[str, Any]) -> List[Dict[str, Any]]: + if _is_error_response(response): + return [] + result = response.get("result", []) + if isinstance(result, dict): + return list(result.get("tracks", []) or []) + if isinstance(result, list): + return result + return [] + + +def _build_config_track_bus_map(config: Dict[str, Any]) -> Dict[str, str]: + mapping: Dict[str, str] = {} + for track in config.get("tracks", []) or []: + if not isinstance(track, dict): + continue + track_name = _normalize_track_name(track.get("name", "")) + bus_key = str(track.get("bus", "") or "").strip().lower() + if track_name and bus_key: + mapping[track_name] = bus_key + return mapping + + +def _match_audio_track_template(track_name: str, mapping: Dict[str, Any]) -> Optional[str]: + normalized = _normalize_track_name(track_name) + if not normalized: + return None + if normalized in mapping: + return normalized + + for template_name in sorted(mapping.keys(), key=len, reverse=True): + if normalized.startswith(f"{template_name} ("): + return template_name + return None + + +def _resolve_bus_key_for_track(track_name: str, config_track_bus_map: Dict[str, str]) -> Optional[str]: + normalized = _normalize_track_name(track_name) + if not normalized: + return None + if normalized in config_track_bus_map: + return config_track_bus_map[normalized] + template_name = _match_audio_track_template(normalized, AUDIO_TRACK_BUS_KEYS) + if template_name: + return AUDIO_TRACK_BUS_KEYS[template_name] + if normalized.startswith("AUDIO VOCAL"): + return "vocal" + if normalized.startswith("AUDIO BASS"): + return "bass" + if normalized.startswith("AUDIO ") and any(token in normalized for token in ("ATMOS", "RISER", "IMPACT", "FX")): + return "fx" + if normalized.startswith("AUDIO "): + return "music" + return None + + +def _normalize_device_key(name: Any) -> str: + return "".join(char for char in str(name or "").strip().lower() if char.isalnum()) + + +def _build_return_device_lookup(ableton: "AbletonConnection", config: Dict[str, Any]) -> Dict[int, Dict[str, List[int]]]: + lookup: Dict[int, Dict[str, List[int]]] = {} + for return_index, _ in enumerate(config.get("returns", []) or []): + try: + response = ableton.send_command("get_devices", { + "track_type": "return", + "track_index": int(return_index), + }) + except Exception: + continue + + device_lookup: Dict[str, List[int]] = {} + for device in _extract_devices_payload(response): + normalized_name = _normalize_device_key(device.get("name", "")) + if not normalized_name: + continue + device_lookup.setdefault(normalized_name, []).append(int(device.get("index", 0))) + lookup[int(return_index)] = device_lookup + return lookup + + +def _build_track_device_lookup(ableton: "AbletonConnection", track_indices: List[int]) -> Dict[int, Dict[str, List[int]]]: + """ + Build a lookup mapping track_index -> device_name -> [device_indices]. + + Similar to _build_return_device_lookup but for regular MIDI/Audio tracks. + """ + lookup: Dict[int, Dict[str, List[int]]] = {} + for track_index in track_indices: + try: + response = ableton.send_command("get_devices", { + "track_index": int(track_index), + }) + except Exception: + continue + + device_lookup: Dict[str, List[int]] = {} + for device in _extract_devices_payload(response): + normalized_name = _normalize_device_key(device.get("name", "")) + if not normalized_name: + continue + device_lookup.setdefault(normalized_name, []).append(int(device.get("index", 0))) + lookup[int(track_index)] = device_lookup + return lookup + + +def _build_bus_device_lookup(ableton: "AbletonConnection", bus_mapping: Dict[str, Dict[str, Any]]) -> Dict[int, Dict[str, List[int]]]: + lookup: Dict[int, Dict[str, List[int]]] = {} + for bus_key, bus_info in bus_mapping.items(): + track_index = int(bus_info.get("track_index", -1)) + if track_index <0: + continue + try: + response = ableton.send_command("get_devices", { + "track_index": track_index, + }) + except Exception: + continue + + device_lookup: Dict[str, List[int]] = {} + for device in _extract_devices_payload(response): + normalized_name = _normalize_device_key(device.get("name", "")) + if not normalized_name: + continue + device_lookup.setdefault(normalized_name, []).append(int(device.get("index", 0))) + lookup[track_index] = device_lookup + return lookup + + +def _resolve_audio_mix_profile(track_name: str, base_volume: float) -> Dict[str, Any]: + normalized = _normalize_track_name(track_name) + template_name = _match_audio_track_template(normalized, AUDIO_LAYER_MIX_PROFILES) + profile = dict(AUDIO_LAYER_MIX_PROFILES.get(template_name or normalized, {})) + profile.setdefault("volume", float(base_volume)) + profile["volume"] = _clamp_float(float(profile.get("volume", base_volume)), 0.0, 1.0) + profile.setdefault("pan", 0.0) + profile.setdefault("sends", {}) + profile.setdefault("fx_chain", []) + return profile + + +def _extract_devices_payload(response: Dict[str, Any]) -> List[Dict[str, Any]]: + if _is_error_response(response): + return [] + result = response.get("result", []) + if isinstance(result, dict): + return list(result.get("devices", []) or []) + if isinstance(result, list): + return result + return [] + + +def _load_audio_fx_chain( + ableton: "AbletonConnection", + track_index: int, + fx_chain: List[Dict[str, Any]], + track_type: str = "track", +) -> int: + if not isinstance(fx_chain, list) or not fx_chain: + return 0 + + loaded = 0 + base_params = {"track_index": track_index} + if track_type and track_type != "track": + base_params["track_type"] = track_type + + for spec in fx_chain: + if not isinstance(spec, dict): + continue + device_name = str(spec.get("device", "") or "").strip() + if not device_name: + continue + + before_devices = _extract_devices_payload(ableton.send_command("get_devices", dict(base_params))) + before_count = len(before_devices) + load_params = dict(base_params) + load_params["device_name"] = device_name + load_response = ableton.send_command("load_device", load_params) + if _is_error_response(load_response): + continue + + after_devices = _extract_devices_payload(ableton.send_command("get_devices", dict(base_params))) + if not after_devices: + continue + + if len(after_devices) > before_count: + device_index = len(after_devices) - 1 + else: + matching = [item for item in after_devices if device_name.lower() in str(item.get("name", "")).lower()] + if not matching: + continue + device_index = int(matching[-1].get("index", len(after_devices) - 1)) + + for param_name, value in dict(spec.get("parameters", {})).items(): + try: + parameter_params = dict(base_params) + parameter_params.update({ + "device_index": device_index, + "parameter": str(param_name), + "value": float(value), + }) + ableton.send_command("set_device_parameter", parameter_params) + except Exception: + pass + loaded += 1 + + return loaded + + +def apply_master_chain(ableton: "AbletonConnection", config: Dict[str, Any]) -> str: + master_spec = config.get("master", {}) or {} + if not isinstance(master_spec, dict): + return "" + + device_chain = [item for item in master_spec.get("device_chain", []) or [] if isinstance(item, dict)] + volume = master_spec.get("volume", None) + base_params = {"track_type": "master", "track_index": 0} + + # Log master profile if present + master_profile_name = master_spec.get("profile", "default") + logger.info("Applying master profile: %s", master_profile_name) + + if volume is not None: + try: + ableton.send_command("set_track_volume", { + "track_type": "master", + "track_index": 0, + "volume": float(volume), + }) + logger.info("Master volume: %.3f", float(volume)) + except Exception: + pass + + loaded = 0 + reused = 0 + existing_devices = _extract_devices_payload(ableton.send_command("get_devices", dict(base_params))) + + for spec in device_chain: + device_name = str(spec.get("device", "") or "").strip() + if not device_name: + continue + + matching = [ + item for item in existing_devices + if device_name.lower() in str(item.get("name", "")).lower() + ] + + if matching: + device_index = int(matching[-1].get("index", 0)) + reused += 1 + else: + load_params = dict(base_params) + load_params["device_name"] = device_name + load_response = ableton.send_command("load_device", load_params) + if _is_error_response(load_response): + continue + existing_devices = _extract_devices_payload(ableton.send_command("get_devices", dict(base_params))) + matching = [ + item for item in existing_devices + if device_name.lower() in str(item.get("name", "")).lower() + ] + if not matching: + continue + device_index = int(matching[-1].get("index", 0)) + loaded += 1 + + for param_name, value in dict(spec.get("parameters", {})).items(): + try: + parameter_params = dict(base_params) + parameter_params.update({ + "device_index": device_index, + "parameter": str(param_name), + "value": float(value), + }) + ableton.send_command("set_device_parameter", parameter_params) + # Log limiter gain specifically + if "limiter" in device_name.lower() and "gain" in str(param_name).lower(): + logger.info("Master limiter gain: %.3f", float(value)) + except Exception: + pass + + if not device_chain and volume is None: + return "" + return f"Master chain: {loaded} devices nuevos, {reused} reutilizados" + + +def _apply_master_state(ableton: "AbletonConnection", master_state: Dict[str, Any]) -> int: + """ + Apply master chain state from performance snapshot. + + Handles device_parameters for master track devices. + Returns count of applied changes. + """ + if not isinstance(master_state, dict): + return 0 + + applied = 0 + base_params = {"track_type": "master", "track_index": 0} + + # Apply volume if specified + if "volume" in master_state: + try: + ableton.send_command("set_track_volume", { + "track_type": "master", + "track_index": 0, + "volume": _linear_to_live_slider(float(master_state["volume"])), + }) + applied += 1 + except Exception: + pass + + # Apply device parameters + for device_state in master_state.get("device_parameters", []) or []: + if not isinstance(device_state, dict): + continue + + device_index = device_state.get("device_index", None) + parameter_name = str(device_state.get("parameter", "") or "").strip() + if not parameter_name: + continue + + # If device_index not provided, try to find by device_name + if device_index is None: + device_name = _normalize_device_key(device_state.get("device_name", "")) + if not device_name: + continue + try: + response = ableton.send_command("get_devices", dict(base_params)) + devices = _extract_devices_payload(response) + for device in devices: + if device_name in str(device.get("name", "")).lower(): + device_index = int(device.get("index", 0)) + break + except Exception: + continue + + if device_index is None: + continue + + try: + parameter_params = dict(base_params) + parameter_params.update({ + "device_index": int(device_index), + "parameter": parameter_name, + "value": float(device_state.get("value", 0.0)), + }) + ableton.send_command("set_device_parameter", parameter_params) + applied += 1 + except Exception: + pass + + return applied + + +def _apply_audio_track_mix( + ableton: "AbletonConnection", + track_index: int, + track_name: str, + base_volume: float, + return_mapping: Dict[str, int], +) -> Dict[str, Any]: + profile = _resolve_audio_mix_profile(track_name, base_volume) + applied_sends = 0 + + ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": _linear_to_live_slider(float(profile.get("volume", base_volume))), + }) + ableton.send_command("set_track_pan", { + "track_index": track_index, + "pan": float(profile.get("pan", 0.0)), + }) + + for send_name, send_value in dict(profile.get("sends", {})).items(): + send_index = return_mapping.get(str(send_name).lower(), None) + if send_index is None: + continue + try: + ableton.send_command("set_track_send", { + "track_index": track_index, + "send_index": int(send_index), + "value": float(send_value), + }) + applied_sends += 1 + except Exception: + pass + + loaded_fx = _load_audio_fx_chain(ableton, track_index, list(profile.get("fx_chain", []) or [])) + return { + "pan": float(profile.get("pan", 0.0)), + "sends": applied_sends, + "fx": loaded_fx, + } + + +def _ensure_mix_bus_tracks(ableton: "AbletonConnection", config: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + bus_specs = [item for item in config.get("buses", []) or [] if isinstance(item, dict)] + if not bus_specs: + return {} + + tracks = _extract_tracks_payload(ableton.send_command("get_tracks")) + existing_by_name = { + _normalize_track_name(track.get("name", "")): track + for track in tracks + if isinstance(track, dict) + } + + bus_mapping: Dict[str, Dict[str, Any]] = {} + for bus_spec in bus_specs: + bus_key = str(bus_spec.get("key", "") or "").strip().lower() + bus_name = str(bus_spec.get("name", bus_key.upper()) or bus_key.upper()).strip() + if not bus_key or not bus_name: + continue + + normalized_name = _normalize_track_name(bus_name) + existing = existing_by_name.get(normalized_name) + created_now = False + + if existing is None: + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + continue + track_index = create_response.get("result", {}).get("index") + if track_index is None: + continue + created_now = True + else: + track_index = int(existing.get("index", -1)) + if track_index < 0: + continue + + ableton.send_command("set_track_name", {"track_index": track_index, "name": bus_name}) + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": int(bus_spec.get("color", 58)), + }) + calibrated_volume = float(bus_spec.get("volume", 0.8)) + ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": _linear_to_live_slider_bus(calibrated_volume), + }) + logger.info("Bus %s calibrated volume: %.3f", bus_name, calibrated_volume) + ableton.send_command("set_track_pan", { + "track_index": track_index, + "pan": float(bus_spec.get("pan", 0.0)), + }) + try: + ableton.send_command("set_track_monitoring", { + "track_index": track_index, + "mode": str(bus_spec.get("monitoring", "in")), + }) + except Exception: + pass + + devices = _extract_devices_payload(ableton.send_command("get_devices", {"track_index": track_index})) + if created_now or not devices: + _load_audio_fx_chain(ableton, track_index, list(bus_spec.get("fx_chain", []) or [])) + + bus_mapping[bus_key] = { + "track_index": int(track_index), + "name": bus_name, + "created": created_now, + } + + return bus_mapping + + +def _route_track_to_mix_bus(ableton: "AbletonConnection", track_index: int, bus_name: str) -> bool: + routing_response = ableton.send_command("get_track_routing", {"track_index": int(track_index)}) + if _is_error_response(routing_response): + return False + + routing = routing_response.get("result", {}) + current_output = _normalize_track_name(routing.get("current_output_routing", "")) + normalized_bus_name = _normalize_track_name(bus_name) + if current_output == normalized_bus_name: + return True + + available = list(routing.get("available_output_routing_types", []) or []) + matched = next( + (option for option in available if _normalize_track_name(option) == normalized_bus_name), + None, + ) + if not matched: + return False + + response = ableton.send_command("set_track_output_routing", { + "track_index": int(track_index), + "routing_name": matched, + }) + return not _is_error_response(response) + + +def apply_mix_bus_architecture(ableton: "AbletonConnection", config: Dict[str, Any]) -> str: + bus_mapping = _ensure_mix_bus_tracks(ableton, config) + if not bus_mapping: + return "" + + config_track_bus_map = _build_config_track_bus_map(config) + bus_track_indices = {int(item["track_index"]) for item in bus_mapping.values()} + tracks = _extract_tracks_payload(ableton.send_command("get_tracks")) + + routed = 0 + skipped = 0 + for track in tracks: + if not isinstance(track, dict): + continue + track_index = int(track.get("index", -1)) + if track_index < 0 or track_index in bus_track_indices: + continue + + bus_key = _resolve_bus_key_for_track(track.get("name", ""), config_track_bus_map) + if not bus_key or bus_key not in bus_mapping: + continue + + if _route_track_to_mix_bus(ableton, track_index, bus_mapping[bus_key]["name"]): + routed += 1 + else: + skipped += 1 + + created_count = sum(1 for item in bus_mapping.values() if item.get("created")) + reused_count = len(bus_mapping) - created_count + return ( + f"Mix buses: {len(bus_mapping)} buses " + f"({created_count} nuevos, {reused_count} reutilizados), " + f"{routed} routings, {skipped} omitidos" + ) + + +def _log_gain_staging_summary(config: Dict[str, Any]) -> None: + """Log the gain staging summary from the config.""" + summary = config.get('gain_staging_summary', {}) + if not summary: + return + + logger.info("=== Gain Staging Summary ===") + logger.info("Master profile: %s", summary.get('master_profile_used')) + logger.info("Style adjustments: %s", summary.get('style_adjustments_applied')) + logger.info("Bus volumes: %s", summary.get('bus_volumes')) + logger.info("Track volume overrides: %d", summary.get('track_volume_overrides_count', 0)) + logger.info("Peak reductions: %d", summary.get('peak_reductions_applied_count', 0)) + logger.info("Headroom target: %s dB", summary.get('headroom_target_db')) + + warnings = summary.get('warnings', []) + if warnings: + logger.warning("Gain staging warnings: %s", warnings) + + +def _iter_device_parameter_states(items: Any) -> List[Dict[str, Any]]: + flattened: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + if "parameter" in item and "value" in item: + flattened.append(item) + continue + device_name = str(item.get("device_name", "") or item.get("name", "")).strip() + for parameter_name, value in dict(item.get("parameters", {})).items(): + flattened.append({ + "device_name": device_name, + "parameter": parameter_name, + "value": value, + }) + return flattened + + +def _apply_performance_snapshot( + ableton: "AbletonConnection", + snapshot: Dict[str, Any], + return_mapping: Dict[str, int], + return_device_lookup: Optional[Dict[int, Dict[str, List[int]]]] = None, + track_device_lookup: Optional[Dict[int, Dict[str, List[int]]]] = None, + bus_device_lookup: Optional[Dict[int, Dict[str, List[int]]]] = None, + master_device_lookup: Optional[Dict[str, List[int]]] = None, + bus_mapping: Optional[Dict[str, Dict[str, Any]]] = None, +) -> int: + if not isinstance(snapshot, dict): + return 0 + + applied = 0 + for track_state in snapshot.get("track_states", []) or []: + if not isinstance(track_state, dict): + continue + track_index = track_state.get("track_index", None) + if track_index is None: + continue + + if "mute" in track_state: + try: + ableton.send_command("set_track_mute", { + "track_index": track_index, + "mute": bool(track_state.get("mute", False)), + }) + applied += 1 + except Exception: + pass + + if "volume" in track_state: + try: + calibrated_volume = float(track_state.get("volume", 0.72)) + ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": _linear_to_live_slider(calibrated_volume), + }) + logger.debug("Track %d calibrated volume: %.3f", track_index, calibrated_volume) + applied += 1 + except Exception: + pass + + if "pan" in track_state: + try: + ableton.send_command("set_track_pan", { + "track_index": track_index, + "pan": float(track_state.get("pan", 0.0)), + }) + applied += 1 + except Exception: + pass + + for send_name, send_value in dict(track_state.get("sends", {})).items(): + send_index = return_mapping.get(str(send_name).lower(), None) + if send_index is None: + continue + try: + ableton.send_command("set_track_send", { + "track_index": track_index, + "send_index": send_index, + "value": float(send_value), + }) + applied += 1 + except Exception: + pass + + # Apply device parameters for regular tracks + devices_for_track = dict((track_device_lookup or {}).get(int(track_index), {})) + for device_state in _iter_device_parameter_states(track_state.get("device_parameters", [])): + if not isinstance(device_state, dict): + continue + parameter_name = str(device_state.get("parameter", "") or "").strip() + if not parameter_name: + continue + + device_index = device_state.get("device_index", None) + if device_index is None: + normalized_name = _normalize_device_key(device_state.get("device_name", "")) + candidates = devices_for_track.get(normalized_name, []) + if candidates: + device_index = candidates[0] + if device_index is None: + continue + + try: + ableton.send_command("set_device_parameter", { + "track_index": int(track_index), + "device_index": int(device_index), + "parameter": parameter_name, + "value": float(device_state.get("value", 0.0)), + }) + applied += 1 + except Exception: + pass + + for return_state in snapshot.get("return_states", []) or []: + if not isinstance(return_state, dict): + continue + + return_index = return_state.get("return_index", None) + if return_index is None: + send_key = str(return_state.get("send_key", "")).strip().lower() + return_index = return_mapping.get(send_key, None) + if return_index is None: + continue + return_index = int(return_index) + + if "mute" in return_state: + try: + ableton.send_command("set_track_mute", { + "track_type": "return", + "track_index": return_index, + "mute": bool(return_state.get("mute", False)), + }) + applied += 1 + except Exception: + pass + + if "volume" in return_state: + try: + ableton.send_command("set_track_volume", { + "track_type": "return", + "track_index": return_index, + "volume": _linear_to_live_slider(float(return_state.get("volume", 0.72))), + }) + applied += 1 + except Exception: + pass + + if "pan" in return_state: + try: + ableton.send_command("set_track_pan", { + "track_type": "return", + "track_index": return_index, + "pan": float(return_state.get("pan", 0.0)), + }) + applied += 1 + except Exception: + pass + + devices_for_return = dict((return_device_lookup or {}).get(return_index, {})) + for device_state in _iter_device_parameter_states(return_state.get("device_parameters", [])): + if not isinstance(device_state, dict): + continue + parameter_name = str(device_state.get("parameter", "") or "").strip() + if not parameter_name: + continue + + device_index = device_state.get("device_index", None) + if device_index is None: + normalized_name = _normalize_device_key(device_state.get("device_name", "")) + candidates = devices_for_return.get(normalized_name, []) + if candidates: + device_index = candidates[0] + if device_index is None: + continue + + try: + ableton.send_command("set_device_parameter", { + "track_type": "return", + "track_index": return_index, + "device_index": int(device_index), + "parameter": parameter_name, + "value": float(device_state.get("value", 0.0)), + }) + applied += 1 + except Exception: + pass + + # Apply bus states + bus_states = snapshot.get("bus_states", []) + if bus_states and bus_mapping: + bus_key_to_index: Dict[str, int] = {} + for bus_key, bus_info in (bus_mapping or {}).items(): + bus_key_to_index[str(bus_key).lower()] = int(bus_info.get("track_index", -1)) + for bus_state in bus_states: + if not isinstance(bus_state, dict): + continue + bus_key = str(bus_state.get("bus_key", "")).lower() + if not bus_key: + continue + bus_track_index = bus_key_to_index.get(bus_key, None) + if bus_track_index is None or bus_track_index <0: + continue + devices_for_bus = dict((bus_device_lookup or {}).get(bus_track_index, {})) + for device_state in _iter_device_parameter_states(bus_state.get("device_parameters", [])): + if not isinstance(device_state, dict): + continue + parameter_name = str(device_state.get("parameter", "") or "").strip() + if not parameter_name: + continue + device_index = device_state.get("device_index", None) + if device_index is None: + normalized_name = _normalize_device_key(device_state.get("device_name", "")) + candidates = devices_for_bus.get(normalized_name, []) + if candidates: + device_index = candidates[0] + if device_index is None: + continue + try: + ableton.send_command("set_device_parameter", { + "track_index": int(bus_track_index), + "device_index": int(device_index), + "parameter": parameter_name, + "value": float(device_state.get("value", 0.0)), + }) + applied +=1 + except Exception: + pass + + # Apply master state + master_state = snapshot.get("master_state", {}) + if isinstance(master_state, dict) and master_state: + # Apply master volume if specified + if "volume" in master_state: + try: + ableton.send_command("set_track_volume", { + "track_type": "master", + "track_index": 0, + "volume": float(master_state["volume"]), + }) + applied += 1 + except Exception: + pass + + # Apply master device parameters + for device_state in _iter_device_parameter_states(master_state.get("device_parameters", [])): + if not isinstance(device_state, dict): + continue + parameter_name = str(device_state.get("parameter", "") or "").strip() + if not parameter_name: + continue + + device_index = device_state.get("device_index", None) + if device_index is None: + normalized_name = _normalize_device_key(device_state.get("device_name", "")) + candidates = dict(master_device_lookup or {}).get(normalized_name, []) + if candidates: + device_index = candidates[0] + if device_index is None: + continue + + try: + ableton.send_command("set_device_parameter", { + "track_type": "master", + "track_index": 0, + "device_index": int(device_index), + "parameter": parameter_name, + "value": float(device_state.get("value", 0.0)), + }) + applied += 1 + except Exception: + pass + + return applied + + +def _resolve_arrangement_locators(config: Dict[str, Any]) -> List[Dict[str, Any]]: + locators = config.get("locators", []) or [] + if isinstance(locators, list) and locators: + return [item for item in locators if isinstance(item, dict)] + + resolved: List[Dict[str, Any]] = [] + arrangement_time = 0.0 + for index, section in enumerate(config.get("sections", []) or []): + if not isinstance(section, dict): + continue + beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) + resolved.append({ + "scene_index": int(section.get("index", index)), + "name": str(section.get("name", "SECTION")), + "bars": int(section.get("bars", max(1, int(beats / 4.0) if beats else 8))), + "color": int(section.get("color", 62)), + "time_beats": arrangement_time, + }) + arrangement_time += max(1.0, beats) + return resolved + + +def _prepare_arrangement_guide_scene_track(ableton: "AbletonConnection", config: Dict[str, Any]) -> str: + locators = _resolve_arrangement_locators(config) + if not locators: + return "" + + create_response = ableton.send_command("create_midi_track", {"index": -1}) + if _is_error_response(create_response): + raise RuntimeError(create_response.get("message", "No se pudo crear ARRANGEMENT GUIDE")) + + guide_index = create_response.get("result", {}).get("index") + if guide_index is None: + session_response = ableton.send_command("get_session_info") + if _is_error_response(session_response): + raise RuntimeError("No se pudo resolver el indice de ARRANGEMENT GUIDE") + guide_index = max(0, int(session_response.get("result", {}).get("num_tracks", 1)) - 1) + + ableton.send_command("set_track_name", {"track_index": guide_index, "name": "ARRANGEMENT GUIDE"}) + ableton.send_command("set_track_color", {"track_index": guide_index, "color": 62}) + ableton.send_command("set_track_volume", {"track_index": guide_index, "volume": 0.0}) + ableton.send_command("set_track_mute", {"track_index": guide_index, "mute": True}) + + created_clips = 0 + for locator in locators: + scene_index = int(locator.get("scene_index", created_clips)) + bars = int(locator.get("bars", 8) or 8) + clip_response = ableton.send_command("create_clip", { + "track_index": guide_index, + "clip_index": scene_index, + "length": max(1.0, bars * 4.0), + "name": "{} [{} bars]".format(locator.get("name", "SECTION"), bars), + }) + if not _is_error_response(clip_response): + ableton.send_command("set_clip_color", { + "track_index": guide_index, + "clip_index": scene_index, + "color": int(locator.get("color", 62)), + }) + ableton.send_command("add_notes", { + "track_index": guide_index, + "clip_index": scene_index, + "notes": [{"pitch": 24, "start": 0.0, "duration": 0.05, "velocity": 1}], + }) + created_clips += 1 + + return "Guide track listo: {} clips de sección".format(created_clips) + + +def apply_arrangement_markers(ableton: "AbletonConnection", config: Dict[str, Any]) -> str: + locators = _resolve_arrangement_locators(config) + if not locators: + return "" + + created_cues = 0 + for locator in locators: + time_beats = float(locator.get("time_beats", 0.0) or 0.0) + cue_response = ableton.send_command("create_cue_point", {"time": time_beats}) + if not _is_error_response(cue_response): + created_cues += 1 + + ableton.send_command("jump_to", {"time": 0}) + ableton.send_command("show_arrangement_view") + + return "Markers de Arrangement: {} locators".format(created_cues) + +def commit_session_blueprint_to_arrangement(ableton: "AbletonConnection", config: Dict[str, Any]) -> str: + """Graba escenas de Session en Arrangement cuando la API no soporta create_midi_clip.""" + sections = config.get("sections", []) or [] + performance = config.get("performance", []) or [] + performance_by_scene = { + int(item.get("scene_index", index)): item + for index, item in enumerate(performance) + if isinstance(item, dict) + } + return_mapping = _build_return_send_mapping(config) + return_device_lookup = _build_return_device_lookup(ableton, config) + + # Build track device lookup for device parameters on regular tracks + track_indices = [] + for track in config.get("tracks", []) or []: + if isinstance(track, dict) and "index" in track: + track_indices.append(int(track["index"])) + track_device_lookup = _build_track_device_lookup(ableton, track_indices) if track_indices else {} + + # Build master device lookup for device parameters on master track + master_device_lookup: Dict[str, List[int]] = {} + try: + response = ableton.send_command("get_devices", {"track_type": "master", "track_index": 0}) + for device in _extract_devices_payload(response): + normalized_name = _normalize_device_key(device.get("name", "")) + if normalized_name: + master_device_lookup.setdefault(normalized_name, []).append(int(device.get("index", 0))) + except Exception: + pass + + # Build bus device lookup for device parameters on bus tracks + bus_mapping = _ensure_mix_bus_tracks(ableton, config) + bus_device_lookup = _build_bus_device_lookup(ableton, bus_mapping) if bus_mapping else {} + + bpm = float(config.get("bpm", 120) or 120) + if not sections: + raise RuntimeError("El blueprint no incluye sections para el commit a Arrangement") + + total_beats = 0.0 + for section in sections: + beats = section.get("beats", None) + if beats is None: + beats = float(section.get("bars", 8)) * 4.0 + total_beats += max(1.0, float(beats)) + + guide_result = _prepare_arrangement_guide_scene_track(ableton, config) + + try: + ableton.send_command("stop") + except Exception: + pass + + ableton.send_command("show_arrangement_view") + ableton.send_command("loop_selection", {"start": 0, "length": total_beats, "enable": False}) + ableton.send_command("jump_to", {"time": 0}) + ableton.send_command("set_record_mode", {"enabled": True}) + snapshot_changes = _apply_performance_snapshot( + ableton, + performance_by_scene.get(0, {}), + return_mapping, + return_device_lookup, + track_device_lookup, + bus_device_lookup, + master_device_lookup, + bus_mapping, + ) + ableton.send_command("fire_scene", {"scene_index": 0}) + time.sleep(0.15) + ableton.send_command("start_playback") + + start_time = time.monotonic() + elapsed_beats = 0.0 + for next_scene_index, section in enumerate(sections[1:], start=1): + previous = sections[next_scene_index - 1] + previous_beats = previous.get("beats", None) + if previous_beats is None: + previous_beats = float(previous.get("bars", 8)) * 4.0 + elapsed_beats += max(1.0, float(previous_beats)) + boundary_time = start_time + (elapsed_beats * 60.0 / bpm) - 0.25 + _sleep_until(boundary_time - 0.12) + snapshot_changes += _apply_performance_snapshot( + ableton, + performance_by_scene.get(next_scene_index, {}), + return_mapping, + return_device_lookup, + track_device_lookup, + bus_device_lookup, + master_device_lookup, + bus_mapping, + ) + _sleep_until(boundary_time) + ableton.send_command("fire_scene", {"scene_index": next_scene_index}) + + finish_time = start_time + (total_beats * 60.0 / bpm) + 0.35 + _sleep_until(finish_time) + ableton.send_command("stop") + ableton.send_command("set_record_mode", {"enabled": False}) + ableton.send_command("jump_to", {"time": 0}) + ableton.send_command("show_arrangement_view") + + commit_result = "Commit a Arrangement completado: {} scenes, {:.1f}s, {} snapshots".format( + len(sections), + total_beats * 60.0 / bpm, + len(performance_by_scene) if performance_by_scene else snapshot_changes, + ) + if guide_result: + commit_result = "{} | {}".format(commit_result, guide_result) + return commit_result + +# Instrucciones para el productor (contexto de IA) +PRODUCER_INSTRUCTIONS = """ +Eres AbletonMCP-AI, un productor musical experto integrado con Ableton Live 12. +Tu objetivo es crear música electrónica profesional mediante prompts en lenguaje natural. + +CAPACIDADES PRINCIPALES: +1. Generar tracks completos con estructura profesional (Intro, Build, Drop, Break, Outro) +2. Crear patrones MIDI para diferentes géneros (Techno, House, Trance, Tech-House, etc.) +3. Seleccionar y cargar samples apropiados para cada elemento (kick, clap, hat, bass, synth) +4. Configurar BPM, tonalidad y estructura musical +5. Aplicar procesamiento de señal básico (volumen, panorama, mute/solo) + +ESTILOS SOPORTADOS: +- Techno: Industrial, Peak Time, Dub, Minimal +- House: Deep, Tech-House, Progressive, Afro, Classic 90s +- Trance: Psy, Progressive, Uplifting +- Otros: Drum & Bass, Garage, EBM + +FLUJO DE TRABAJO: +1. Analizar el prompt del usuario para extraer género, BPM, tonalidad, mood +2. Seleccionar samples apropiados del índice +3. Generar patrones MIDI característicos del género +4. Crear estructura de tracks en Ableton +5. Configurar mezcla básica (niveles, paneo) +6. Proporcionar feedback sobre lo creado + +REGLAS: +- Siempre verifica la conexión con Ableton antes de ejecutar comandos +- Usa valores por defecto razonables si el usuario no especifica +- Organiza los tracks con colores consistentes (Drums=Rojo, Bass=Azul, Synths=Amarillo, etc.) +- Crea clips nombrados apropiadamente ("Kick Loop", "Bassline", "Chord Stab") +- Mantén headroom en la mezcla (master sin clip) +""".strip() + + +def _normalize_command_payload(command_type: str, params: Optional[Dict[str, Any]]) -> Tuple[str, Dict[str, Any]]: + """Normalize MCP-level aliases to the Remote Script protocol.""" + normalized_type = command_type + normalized_params = dict(params or {}) + + if normalized_type == "create_midi_track": + normalized_type = "create_track" + normalized_params.setdefault("type", "midi") + elif normalized_type == "create_audio_track": + normalized_type = "create_track" + normalized_params.setdefault("type", "audio") + elif normalized_type == "add_notes_to_clip": + normalized_type = "add_notes" + elif normalized_type == "start_playback": + normalized_type = "play" + elif normalized_type == "stop_playback": + normalized_type = "stop" + elif normalized_type == "generate_track": + normalized_type = "generate_complete_song" + + if normalized_type in TRACK_INDEX_COMMANDS and "track_index" in normalized_params: + normalized_params.setdefault("index", normalized_params["track_index"]) + + if normalized_type in CLIP_SCENE_COMMANDS and "clip_index" in normalized_params: + normalized_params.setdefault("scene_index", normalized_params["clip_index"]) + + if normalized_type in SCENE_INDEX_COMMANDS and "scene_index" in normalized_params: + normalized_params.setdefault("index", normalized_params["scene_index"]) + + return normalized_type, normalized_params + + +def _is_error_response(response: Dict[str, Any]) -> bool: + return response.get("status") != "success" + + +@dataclass +class AbletonConnection: + """Gestiona la conexión con Ableton Live""" + host: str = HOST + port: int = DEFAULT_PORT + sock: Optional[socket.socket] = None + _connection_timeout: float = 5.0 + _max_retries: int = 3 + _retry_delay: float = 0.5 + + def connect(self) -> bool: + """Conecta al Remote Script de Ableton""" + if self.sock: + return True + + last_error = None + for attempt in range(self._max_retries): + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(self._connection_timeout) + self.sock.connect((self.host, self.port)) + logger.info(f"Conectado a Ableton en {self.host}:{self.port}") + return True + except socket.timeout as e: + last_error = e + logger.warning(f"Timeout conectando a Ableton (intento {attempt + 1}/{self._max_retries})") + except ConnectionRefusedError as e: + last_error = e + logger.warning(f"Conexion rechazada por Ableton (intento {attempt + 1}/{self._max_retries})") + except OSError as e: + last_error = e + logger.warning(f"Error de OS conectando a Ableton: {e} (intento {attempt + 1}/{self._max_retries})") + except Exception as e: + last_error = e + logger.error(f"Error inesperado conectando a Ableton: {e}") + + self.sock = None + if attempt < self._max_retries - 1: + time.sleep(self._retry_delay) + + logger.error(f"Error conectando a Ableton despues de {self._max_retries} intentos: {last_error}") + return False + + def disconnect(self): + """Desconecta de Ableton""" + if self.sock: + try: + self.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + except Exception as e: + logger.debug(f"Error en shutdown de socket: {e}") + try: + self.sock.close() + except Exception as e: + logger.debug(f"Error cerrando socket: {e}") + finally: + self.sock = None + + def _validate_command_params(self, command_type: str, params: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Validate and normalize command parameters.""" + if params is None: + return {} + + if not isinstance(params, dict): + raise ValidationError("params", params, "dictionary") + + return params + + def send_command(self, command_type: str, params: Dict[str, Any] = None, timeout: float = 15.0) -> Dict[str, Any]: + """Envía un comando a Ableton y retorna la respuesta""" + try: + _validate_string(command_type, "command_type", allow_empty=False) + except ValidationError: + raise ValidationError("command_type", command_type, "non-empty string") + + if self.sock: + self.disconnect() + + normalized_type, normalized_params = _normalize_command_payload(command_type, params) + resolved_timeout = max(float(timeout or 0.0), COMMAND_TIMEOUTS.get(normalized_type, 15.0)) + + command = { + "type": normalized_type, + "params": normalized_params + } + + operation_id = f"{normalized_type}_{int(time.time() * 1000)}" + start_time = time.monotonic() + + try: + if normalized_type != command_type: + logger.info(f"Enviando comando: {command_type} -> {normalized_type}") + else: + logger.info(f"Enviando comando: {command_type}") + + payload = json.dumps(command, separators=(',', ':')).encode('utf-8') + MESSAGE_TERMINATOR + + sock = None + try: + sock = socket.create_connection((self.host, self.port), timeout=resolved_timeout) + sock.settimeout(resolved_timeout) + sock.sendall(payload) + + buffer = b"" + chunks_received = 0 + max_chunks = 1000 # Prevent infinite loops + + while chunks_received < max_chunks: + try: + chunk = sock.recv(8192) + if not chunk: + logger.warning(f"Conexion cerrada por Ableton despues de {chunks_received} chunks") + break + + chunks_received += 1 + buffer += chunk + + if MESSAGE_TERMINATOR not in buffer: + continue + + raw_response, _, remainder = buffer.partition(MESSAGE_TERMINATOR) + buffer = remainder + + try: + response = json.loads(raw_response.decode('utf-8')) + elapsed = time.monotonic() - start_time + logger.debug(f"Comando {normalized_type} completado en {elapsed:.3f}s") + return response + except json.JSONDecodeError as e: + logger.warning(f"Respuesta JSON invalida: {e}") + continue + + except socket.timeout: + elapsed = time.monotonic() - start_time + logger.warning(f"Timeout esperando respuesta despues de {elapsed:.1f}s") + raise TimeoutError(normalized_type, resolved_timeout, { + "operation_id": operation_id, + "elapsed_seconds": elapsed + }) + + # Si llegamos aqui, la respuesta puede estar incompleta + if buffer: + try: + response = json.loads(buffer.decode('utf-8').strip()) + logger.warning("Respuesta JSON recibida sin terminador") + return response + except json.JSONDecodeError as e: + raise ConnectionError(f"Respuesta JSON incompleta: {e}") + + raise ConnectionError("No se recibio respuesta de Ableton") + + finally: + if sock: + try: + sock.close() + except Exception: + pass + + except MCPError: + raise + except socket.timeout: + elapsed = time.monotonic() - start_time + raise TimeoutError(normalized_type, resolved_timeout, { + "operation_id": operation_id, + "elapsed_seconds": elapsed + }) + except ConnectionRefusedError: + raise ConnectionError(f"Ableton no esta aceptando conexiones en {self.host}:{self.port}") + except Exception as e: + _log_error(e, context=f"send_command({normalized_type})") + raise ConnectionError(f"Error de comunicacion con Ableton: {e}") + + +# Conexión global +_ableton_connection: Optional[AbletonConnection] = None +_sample_index: Optional['SampleIndex'] = None +_song_generator: Optional['SongGenerator'] = None +_sample_manager: Optional['SampleManager'] = None +_sample_selector: Optional['SampleSelector'] = None +_reference_listener: Optional['ReferenceAudioListener'] = None +_audio_resampler: Optional['AudioResampler'] = None + + +def get_ableton_connection() -> AbletonConnection: + """Obtiene o crea la conexión con Ableton""" + global _ableton_connection + if _ableton_connection is None: + _ableton_connection = AbletonConnection() + return _ableton_connection + + +def _ensure_ableton_connection() -> AbletonConnection: + """Ensure Ableton connection is available, raise ConnectionError if not.""" + ableton = get_ableton_connection() + if ableton is None: + raise ConnectionError("Ableton connection not initialized") + return ableton + + +def get_sample_index() -> 'SampleIndex': + """Obtiene o crea el índice de samples""" + global _sample_index + if _sample_index is None and SampleIndex is not None: + try: + _sample_index = SampleIndex(SAMPLES_DIR) + except Exception as e: + _log_error(e, context="get_sample_index") + raise DependencyError("SampleIndex", {"original_error": str(e)}) + elif SampleIndex is None: + raise DependencyError("SampleIndex") + return _sample_index + + +def get_sample_manager() -> Optional['SampleManager']: + """Obtiene o crea el gestor de samples""" + global _sample_manager + if _sample_manager is None and SAMPLE_SYSTEM_AVAILABLE and sample_manager_factory is not None: + try: + _sample_manager = sample_manager_factory(SAMPLES_DIR) + except Exception as e: + _log_error(e, context="get_sample_manager") + return None + return _sample_manager + + +def _ensure_sample_manager() -> 'SampleManager': + """Ensure SampleManager is available, raise DependencyError if not.""" + manager = get_sample_manager() + if manager is None: + raise DependencyError("SampleManager") + return manager + + +def get_sample_selector() -> Optional['SampleSelector']: + """Obtiene o crea el selector de samples""" + global _sample_selector + if _sample_selector is None and SAMPLE_SYSTEM_AVAILABLE and SampleSelector is not None: + try: + manager = get_sample_manager() + if manager: + _sample_selector = SampleSelector(manager) + except Exception as e: + _log_error(e, context="get_sample_selector") + return None + return _sample_selector + + +def _ensure_sample_selector() -> 'SampleSelector': + """Ensure SampleSelector is available, raise DependencyError if not.""" + selector = get_sample_selector() + if selector is None: + raise DependencyError("SampleSelector") + return selector + + +def get_song_generator() -> 'SongGenerator': + """Obtiene o crea el generador de canciones""" + global _song_generator + if _song_generator is None and SongGenerator is not None: + try: + _song_generator = SongGenerator() + except Exception as e: + _log_error(e, context="get_song_generator") + raise DependencyError("SongGenerator", {"original_error": str(e)}) + elif SongGenerator is None: + raise DependencyError("SongGenerator") + return _song_generator + + +def _ensure_song_generator() -> 'SongGenerator': + """Ensure SongGenerator is available, raise DependencyError if not.""" + if SongGenerator is None: + raise DependencyError("SongGenerator") + return get_song_generator() + + +def get_reference_listener() -> Optional['ReferenceAudioListener']: + """Obtiene el analizador de referencia basado en audio.""" + global _reference_listener + if _reference_listener is None and ReferenceAudioListener is not None: + try: + _reference_listener = ReferenceAudioListener(SAMPLES_DIR) + except Exception as e: + _log_error(e, context="get_reference_listener") + return None + return _reference_listener + + +def get_audio_resampler() -> Optional['AudioResampler']: + """Obtiene el generador de transiciones derivadas desde audio.""" + global _audio_resampler + if _audio_resampler is None and AudioResampler is not None: + try: + _audio_resampler = AudioResampler() + except Exception as e: + _log_error(e, context="get_audio_resampler") + return None + return _audio_resampler + + +def _send_ableton_command_safe(ableton: AbletonConnection, command: str, params: Dict[str, Any] = None, timeout: float = 15.0) -> Dict[str, Any]: + """Send a command to Ableton with proper error handling.""" + try: + response = ableton.send_command(command, params, timeout=timeout) + if _is_error_response(response): + raise AbletonResponseError(command, response) + return response + except MCPError: + raise + except Exception as e: + _log_error(e, context=f"_send_ableton_command_safe({command})") + raise ConnectionError(f"Failed to send command '{command}': {e}") + + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Maneja el ciclo de vida del servidor""" + try: + logger.info("AbletonMCP-AI Server iniciando...") + + # Intentar conectar a Ableton + try: + ableton = get_ableton_connection() + if ableton.connect(): + logger.info("✓ Conectado a Ableton Live") + else: + logger.warning("⚠ No se pudo conectar a Ableton (¿está abierto el script?)") + except Exception as e: + logger.warning(f"⚠ Error conectando a Ableton: {e}") + + # Inicializar índice de samples (legacy) + try: + sample_index = get_sample_index() + logger.info(f"✓ Índice de samples cargado: {len(sample_index.samples)} samples") + except Exception as e: + logger.warning(f"⚠ Error cargando índice de samples: {e}") + + # Inicializar nuevo sistema de samples + try: + sample_manager = get_sample_manager() + if sample_manager: + logger.info("✓ Sistema de samples inicializado") + # Escanear si está vacío + if len(sample_manager.samples) == 0: + logger.info("Escaneando librería de samples...") + stats = sample_manager.scan_directory() + logger.info(f" → {stats['added']} samples agregados") + except Exception as e: + logger.warning(f"⚠ Error inicializando sistema de samples: {e}") + + try: + installed_device = ensure_m4l_sampler_device_installed() + logger.info(f"✓ Device M4L instalado: {installed_device}") + except Exception as e: + logger.warning(f"⚠ Error instalando device M4L: {e}") + + yield {} + + finally: + global _ableton_connection + if _ableton_connection: + logger.info("Desconectando de Ableton...") + _ableton_connection.disconnect() + _ableton_connection = None + logger.info("AbletonMCP-AI Server detenido") + + +# Crear el servidor MCP +mcp = FastMCP( + "AbletonMCP-AI", + instructions=PRODUCER_INSTRUCTIONS, + lifespan=server_lifespan +) + + +# ============================================================================ +# HERRAMIENTAS MCP - Información +# ============================================================================ + +@mcp.tool() +def get_session_info(ctx: Context) -> str: + """Obtiene información de la sesión actual de Ableton""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("get_session_info") + + if response.get("status") == "success": + result = response["result"] + return json.dumps(result, indent=2) + else: + return f"Error: {response.get('message', 'Unknown error')}" + + except Exception as e: + return f"Error obteniendo información: {str(e)}" + + +@mcp.tool() +def get_tracks(ctx: Context) -> str: + """Lista todos los tracks en la sesión actual""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("get_tracks") + + if response.get("status") == "success": + tracks = response["result"] + return json.dumps(tracks, indent=2) + else: + return _handle_tool_error( + AbletonResponseError("get_tracks", response), + "get_tracks" + ) + + except MCPError as e: + return _handle_tool_error(e, "get_tracks") + except Exception as e: + return _handle_tool_error(e, "get_tracks") + + +@mcp.tool() +def get_track_info(ctx: Context, track_index: int) -> str: + """Obtiene información detallada de un track específico""" + try: + # Validate parameter + track_index = _validate_int(track_index, "track_index", min_val=0) + + ableton = get_ableton_connection() + tracks_response = ableton.send_command("get_tracks") + + if _is_error_response(tracks_response): + return _handle_tool_error( + AbletonResponseError("get_tracks", tracks_response), + "get_track_info" + ) + + tracks = tracks_response.get("result", []) + if track_index >= len(tracks): + return _handle_tool_error( + ValidationError("track_index", track_index, f"index < {len(tracks)} (number of tracks)"), + "get_track_info" + ) + + track_info = dict(tracks[track_index]) + + clips_response = ableton.send_command("get_clips", {"track_index": track_index}) + if not _is_error_response(clips_response): + track_info["clips"] = clips_response.get("result", []) + + devices_response = ableton.send_command("get_devices", {"track_index": track_index}) + if not _is_error_response(devices_response): + track_info["devices"] = devices_response.get("result", []) + + return json.dumps(track_info, indent=2) + + except MCPError as e: + return _handle_tool_error(e, "get_track_info") + except Exception as e: + return _handle_tool_error(e, "get_track_info") + + +# ============================================================================ +# HERRAMIENTAS MCP - Creación de Tracks +# ============================================================================ + +@mcp.tool() +def create_midi_track(ctx: Context, index: int = -1, name: str = "MIDI Track") -> str: + """Crea un nuevo track MIDI""" + try: + # Validate parameters + index = _validate_int(index, "index", min_val=-1) + name = _validate_string(name, "name", allow_empty=True) + + ableton = get_ableton_connection() + response = ableton.send_command("create_midi_track", {"index": index}) + + if response.get("status") == "success": + # Setear nombre si se proporcionó + if name: + track_idx = response["result"].get("index", index if index >= 0 else 0) + try: + ableton.send_command("set_track_name", { + "track_index": track_idx, + "name": name + }) + except Exception as e: + _log_error(e, context="create_midi_track:set_track_name") + return f"Track MIDI '{name}' creado exitosamente" + else: + return _handle_tool_error( + AbletonResponseError("create_midi_track", response), + "create_midi_track" + ) + + except MCPError as e: + return _handle_tool_error(e, "create_midi_track") + except Exception as e: + return _handle_tool_error(e, "create_midi_track") + + +@mcp.tool() +def create_audio_track(ctx: Context, index: int = -1, name: str = "Audio Track") -> str: + """Crea un nuevo track de audio""" + try: + # Validate parameters + index = _validate_int(index, "index", min_val=-1) + name = _validate_string(name, "name", allow_empty=True) + + ableton = get_ableton_connection() + response = ableton.send_command("create_audio_track", {"index": index}) + + if response.get("status") == "success": + if name: + track_idx = response["result"].get("index", index if index >= 0 else 0) + try: + ableton.send_command("set_track_name", { + "track_index": track_idx, + "name": name + }) + except Exception as e: + _log_error(e, context="create_audio_track:set_track_name") + return f"Track de audio '{name}' creado exitosamente" + else: + return _handle_tool_error( + AbletonResponseError("create_audio_track", response), + "create_audio_track" + ) + + except MCPError as e: + return _handle_tool_error(e, "create_audio_track") + except Exception as e: + return _handle_tool_error(e, "create_audio_track") + + +@mcp.tool() +def set_track_name(ctx: Context, track_index: int, name: str) -> str: + """Cambia el nombre de un track""" + try: + # Validate parameters + track_index = _validate_int(track_index, "track_index", min_val=0) + name = _validate_string(name, "name", allow_empty=False) + + ableton = get_ableton_connection() + response = ableton.send_command("set_track_name", { + "track_index": track_index, + "name": name + }) + + if response.get("status") == "success": + return f"Track {track_index} renombrado a '{name}'" + else: + return _handle_tool_error( + AbletonResponseError("set_track_name", response), + "set_track_name" + ) + + except MCPError as e: + return _handle_tool_error(e, "set_track_name") + except Exception as e: + return _handle_tool_error(e, "set_track_name") + + +@mcp.tool() +def set_track_color(ctx: Context, track_index: int, color: int) -> str: + """ + Cambia el color de un track (0-69) + + Colores comunes: + - 0-9: Rojos + - 10-19: Naranjas/Amarillos + - 20-29: Verdes + - 30-39: Azules + - 40-49: Morados/Rosas + - 50-59: Grises + - 60-69: Especiales + """ + try: + # Validate parameters + track_index = _validate_int(track_index, "track_index", min_val=0) + color = _validate_int(color, "color", min_val=0, max_val=69) + + ableton = get_ableton_connection() + response = ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + if response.get("status") == "success": + return f"Color del track {track_index} actualizado" + else: + return _handle_tool_error( + AbletonResponseError("set_track_color", response), + "set_track_color" + ) + + except MCPError as e: + return _handle_tool_error(e, "set_track_color") + except Exception as e: + return _handle_tool_error(e, "set_track_color") + + +@mcp.tool() +def set_track_volume(ctx: Context, track_index: int, volume: float, track_type: str = "track") -> str: + """ + Ajusta el volumen de un track (0.0 - 1.0) + + Valores típicos: + - 0.0: Silencio + - 0.5: -6dB + - 0.7: -3dB + - 0.85: 0dB (unity) + - 1.0: +6dB + """ + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_track_volume", { + "track_index": track_index, + "track_type": track_type, + "volume": volume + }) + + if response.get("status") == "success": + db = 20 * (volume - 0.85) / 0.85 # Aproximación + target_label = "return" if str(track_type).lower() == "return" else "track" + return f"✓ Volumen del {target_label} {track_index} ajustado ({volume:.2f}, ~{db:+.1f}dB)" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def set_track_pan(ctx: Context, track_index: int, pan: float, track_type: str = "track") -> str: + """ + Ajusta el paneo de un track (-1.0 a 1.0) + + Valores: + - -1.0: Izquierda completa + - 0.0: Centro + - 1.0: Derecha completa + """ + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_track_pan", { + "track_index": track_index, + "track_type": track_type, + "pan": pan + }) + + if response.get("status") == "success": + pos = "centro" if pan == 0 else f"{'izq' if pan < 0 else 'der'} {abs(pan)*100:.0f}%" + target_label = "return" if str(track_type).lower() == "return" else "track" + return f"✓ Paneo del {target_label} {track_index}: {pos}" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def set_track_send(ctx: Context, track_index: int, send_index: int, value: float, track_type: str = "track") -> str: + """ + Ajusta el nivel de un send de un track (0.0 - 1.0) + """ + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_track_send", { + "track_index": track_index, + "track_type": track_type, + "send_index": send_index, + "value": max(0.0, min(1.0, value)) + }) + + if response.get("status") == "success": + target_label = "return" if str(track_type).lower() == "return" else "track" + return f"✓ Send {send_index} del {target_label} {track_index} ajustado a {value:.2f}" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def set_track_mute(ctx: Context, track_index: int, mute: bool, track_type: str = "track") -> str: + """Activa/desactiva mute de un track""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_track_mute", { + "track_index": track_index, + "track_type": track_type, + "mute": mute + }) + + if response.get("status") == "success": + estado = "muteado" if mute else "desmuteado" + target_label = "Return" if str(track_type).lower() == "return" else "Track" + return f"✓ {target_label} {track_index} {estado}" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def set_track_solo(ctx: Context, track_index: int, solo: bool) -> str: + """Activa/desactiva solo de un track""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_track_solo", { + "track_index": track_index, + "solo": solo + }) + + if response.get("status") == "success": + estado = "en solo" if solo else "sin solo" + return f"✓ Track {track_index} {estado}" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - Clips y Notas +# ============================================================================ + +@mcp.tool() +def create_clip(ctx: Context, track_index: int, clip_index: int, length: float = 4.0, name: str = "") -> str: + """ + Crea un clip MIDI en un slot específico + + Args: + track_index: Índice del track + clip_index: Índice del slot/scene + length: Duración en beats (default 4.0 = 1 compás) + name: Nombre opcional para el clip + """ + try: + ableton = get_ableton_connection() + + # Crear clip + response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if response.get("status") == "success": + # Setear nombre si se proporcionó + if name: + ableton.send_command("set_clip_name", { + "track_index": track_index, + "clip_index": clip_index, + "name": name + }) + + return f"✓ Clip creado en track {track_index}, slot {clip_index} ({length} beats)" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error creando clip: {str(e)}" + + +@mcp.tool() +def add_notes_to_clip( + ctx: Context, + track_index: int, + clip_index: int, + notes: str +) -> str: + """ + Agrega notas MIDI a un clip existente + + Args: + track_index: Índice del track + clip_index: Índice del clip/slot + notes: JSON array de notas [{"pitch": 60, "start": 0.0, "duration": 0.25, "velocity": 100}, ...] + + Notas MIDI comunes: + - C1 (36): Kick + - D1 (38): Snare + - F#1 (42): Closed Hi-hat + - A#1 (46): Open Hi-hat + - D2 (50): Clap + - C3 (60): C central + """ + try: + notes_list = json.loads(notes) + + ableton = get_ableton_connection() + response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes_list + }) + + if response.get("status") == "success": + result = response.get("result", {}) + count = result.get("num_notes_added", result.get("notes_added", len(notes_list))) + return f"✓ {count} notas agregadas al clip" + else: + return f"✗ Error: {response.get('message')}" + + except json.JSONDecodeError: + return "✗ Error: El parámetro 'notes' debe ser un JSON válido" + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def fire_clip(ctx: Context, track_index: int, clip_index: int) -> str: + """Dispara/reproduce un clip específico""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("fire_clip", { + "track_index": track_index, + "clip_index": clip_index + }) + + if response.get("status") == "success": + return f"▶ Clip en track {track_index}, slot {clip_index} disparado" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def stop_clip(ctx: Context, track_index: int, clip_index: int) -> str: + """Detiene un clip específico""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("stop_clip", { + "track_index": track_index, + "clip_index": clip_index + }) + + if response.get("status") == "success": + return f"⏹ Clip en track {track_index}, slot {clip_index} detenido" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - Transporte y Tempo +# ============================================================================ + +@mcp.tool() +def set_tempo(ctx: Context, tempo: float) -> str: + """ + Cambia el BPM/tempo de la sesión + + Rangos típicos por género: + - Techno: 125-140 BPM + - House: 120-128 BPM + - Tech-House: 124-128 BPM + - Trance: 135-150 BPM + - Drum & Bass: 160-180 BPM + """ + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_tempo", {"tempo": tempo}) + + if response.get("status") == "success": + return f"♩ Tempo cambiado a {tempo} BPM" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def start_playback(ctx: Context) -> str: + """Inicia la reproducción""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("start_playback") + + if response.get("status") == "success": + try: + send_m4l_sampler_command("start") + except Exception: + pass + return "▶ Reproducción iniciada" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def stop_playback(ctx: Context) -> str: + """Detiene la reproducción""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("stop_playback") + + if response.get("status") == "success": + try: + send_m4l_sampler_command("stop") + except Exception: + pass + return "⏹ Reproducción detenida" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - Scenes +# ============================================================================ + +@mcp.tool() +def create_scene(ctx: Context, index: int = -1, name: str = "") -> str: + """Crea una nueva scene""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("create_scene", {"index": index}) + + if response.get("status") == "success": + # Setear nombre si se proporcionó + if name: + scene_idx = response["result"].get("index", index if index >= 0 else 0) + ableton.send_command("set_scene_name", { + "scene_index": scene_idx, + "name": name + }) + return f"✓ Scene '{name}' creada" if name else "✓ Scene creada" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def set_scene_name(ctx: Context, scene_index: int, name: str) -> str: + """Cambia el nombre de una scene""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("set_scene_name", { + "scene_index": scene_index, + "name": name + }) + + if response.get("status") == "success": + return f"✓ Scene {scene_index} renombrada a '{name}'" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def fire_scene(ctx: Context, scene_index: int) -> str: + """Dispara una scene (todos sus clips)""" + try: + ableton = get_ableton_connection() + response = ableton.send_command("fire_scene", {"scene_index": scene_index}) + + if response.get("status") == "success": + return f"▶ Scene {scene_index} disparada" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - Generación Musical (AI) +# ============================================================================ + +@mcp.tool() +def generate_track( + ctx: Context, + genre: str, + style: str = "", + bpm: float = 0, + key: str = "", + structure: str = "standard" +) -> str: + """ + Genera un track completo con IA basado en parámetros musicales + + Args: + genre: Género musical (techno, house, trance, tech-house, drum-and-bass) + style: Sub-género o estilo específico (e.g., "industrial", "deep", "90s", "minimal") + bpm: BPM deseado (0 = auto-seleccionar según género) + key: Tonalidad (e.g., "Am", "F#m", "C") - vacío = auto-seleccionar + structure: Estructura del track (standard, minimal, extended) + + Ejemplos: + - generate_track("techno", "industrial", 138, "F#m") + - generate_track("house", "deep", 124, "Am") + - generate_track("tech-house", "groovy", 126) + """ + try: + if SongGenerator is None: + return "✗ Error: Módulo song_generator no disponible" + + generator = get_song_generator() + + # Iniciar tracking de esta generación + selector = get_sample_selector() + if hasattr(selector, 'start_generation_tracking'): + selector.start_generation_tracking() + listener = get_reference_listener() + if listener is not None and hasattr(listener, 'start_generation_tracking'): + listener.start_generation_tracking() + + # Generar configuración del track + config = generator.generate_config(genre, style, bpm, key, structure) + + # Log section variants + sections = config.get("sections", []) or [] + if sections: + logger.info("SECTION_VARIANTS: %d sections generated", len(sections)) + for i, section in enumerate(sections[:5]): # First 5 + kind = section.get('kind', 'unknown') + drum_var = section.get('drum_variant', 'default') + bass_var = section.get('bass_variant', 'default') + mel_var = section.get('melodic_variant', 'default') + logger.info(" Section %d (%s): drum=%s, bass=%s, melodic=%s", + i, kind, drum_var, bass_var, mel_var) + if len(sections) > 5: + logger.info(" ... and %d more sections", len(sections) - 5) + + # Log pattern bank usage if available + if 'pattern_bank_hits' in config: + logger.debug("PATTERN_BANK: %d patterns from bank", + sum(config['pattern_bank_hits'].values())) + + # Log gain staging summary if available + _log_gain_staging_summary(config) + + reference_audio_plan = _build_reference_audio_plan(config) + total_beats = int(config.get("total_beats", 16) or 16) + runtime_config = dict(config) + runtime_config.pop("reference_audio_plan", None) + + # Enviar a Ableton + ableton = get_ableton_connection() + response = ableton.send_command("generate_track", runtime_config) + + if response.get("status") == "success": + runtime_result = response.get("result", {}) + runtime_bpm = runtime_result.get("bpm", config.get("bpm", bpm)) + runtime_key = runtime_result.get("key", config.get("key", key)) + resolved_genre = str(config.get("genre", genre)).strip() + resolved_style = str(config.get("style", style)).strip() + title_parts = [resolved_genre.title()] + if resolved_style: + title_parts.append(resolved_style.title()) + + parts = ["✓ Track generado exitosamente!"] + parts.append(f"Tema: {' '.join(title_parts)}") + parts.append(f"BPM: {runtime_bpm}") + + resolved_key = runtime_key + if resolved_key: + parts.append(f"Key: {resolved_key}") + + if resolved_style: + parts.append(f"Style: {resolved_style}") + if config.get("arrangement_profile"): + parts.append(f"Profile: {config['arrangement_profile']}") + if config.get("reference_track"): + parts.append(f"Referencia: {config['reference_track'].get('name')}") + + actual_tracks = runtime_result.get("tracks") + actual_scenes = runtime_result.get("scenes") + actual_returns = runtime_result.get("return_tracks") + actual_cue_points = runtime_result.get("cue_points") + actual_structure = runtime_result.get("structure", structure) + playback_mode = runtime_result.get("playback_mode", "session") + arrangement_result = "" + marker_result = "" + hybrid_result = "" + bus_result = "" + master_result = "" + + def refresh_runtime_counts() -> None: + nonlocal actual_tracks, actual_scenes, actual_returns, actual_cue_points + session_response = ableton.send_command("get_session_info") + if _is_error_response(session_response): + return + session_info = session_response.get("result", {}) + actual_tracks = session_info.get("num_tracks", actual_tracks) + actual_scenes = session_info.get("num_scenes", actual_scenes) + actual_returns = session_info.get("num_return_tracks", actual_returns) + actual_cue_points = session_info.get("num_cue_points", actual_cue_points) + + if reference_audio_plan: + reference_info = reference_audio_plan.get("reference", {}) + parts.append(f"Referencia escuchada con: {reference_info.get('device', 'numpy')}") + if reference_info.get("variant_seed") is not None: + parts.append(f"Variante: {reference_info.get('variant_seed')}") + + if runtime_result.get("requires_arrangement_commit"): + arrangement_result = commit_session_blueprint_to_arrangement(ableton, config) + playback_mode = "arrangement" + refresh_runtime_counts() + + if reference_audio_plan: + try: + fallback_result = setup_audio_sample_fallback( + genre=resolved_genre, + style=resolved_style, + key=resolved_key or "", + bpm=float(runtime_bpm) if runtime_bpm else 0, + total_beats=total_beats, + config=config, + ) + hybrid_result = "\n".join([item for item in [hybrid_result, fallback_result] if item]) + playback_mode = "arrangement" + refresh_runtime_counts() + except Exception as audio_fallback_error: + fallback_error = f"Audio reference fallback no disponible: {audio_fallback_error}" + hybrid_result = "\n".join([item for item in [hybrid_result, fallback_error] if item]) + else: + # Sin reference_audio_plan: intentar hybrid sampler o fallback estandar + try: + hybrid_result = setup_hybrid_m4l_sampler( + genre=resolved_genre, + style=resolved_style, + key=resolved_key or "", + bpm=float(runtime_bpm) if runtime_bpm else 0, + ) + if hybrid_result: + refresh_runtime_counts() + except Exception as hybrid_error: + hybrid_result = f"Modo híbrido no disponible: {hybrid_error}" + try: + fallback_result = setup_audio_sample_fallback( + genre=resolved_genre, + style=resolved_style, + key=resolved_key or "", + bpm=float(runtime_bpm) if runtime_bpm else 0, + total_beats=total_beats, + config=config, + ) + hybrid_result = "\n".join([item for item in [hybrid_result, fallback_result] if item]) + playback_mode = "arrangement" + refresh_runtime_counts() + except Exception as audio_fallback_error: + hybrid_result = "\n".join([ + item for item in [ + hybrid_result, + f"Audio fallback no disponible: {audio_fallback_error}", + ] if item + ]) + + if playback_mode == "arrangement": + try: + marker_result = apply_arrangement_markers(ableton, config) + refresh_runtime_counts() + except Exception as marker_error: + marker_result = f"Markers de Arrangement no disponibles: {marker_error}" + + try: + resampler = get_audio_resampler() + if resampler is not None: + sections = config.get("sections", []) + derived_layers = resampler.build_transition_layers( + {"matches": {}}, + sections, + float(runtime_bpm) if runtime_bpm else 138.0, + ) + if derived_layers: + logger.info("Creating %d derived FX layers from local library", len(derived_layers)) + for layer in derived_layers: + try: + create_response = ableton.send_command("create_audio_track", {"index": -1}) + if _is_error_response(create_response): + continue + track_index = create_response.get("result", {}).get("index") + if track_index is None: + continue + ableton.send_command("set_track_name", {"track_index": track_index, "name": layer["name"]}) + ableton.send_command("set_track_color", {"track_index": track_index, "color": layer.get("color", 20)}) + ableton.send_command("set_track_volume", {"track_index": track_index, "volume": _linear_to_live_slider(layer.get("volume", 0.5))}) + ableton.send_command("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": layer["file_path"], + "positions": layer["positions"], + "name": layer["name"], + }) + hybrid_result = f"{hybrid_result}\n{layer['name']}: {Path(layer['file_path']).name}" if hybrid_result else f"{layer['name']}: {Path(layer['file_path']).name}" + except Exception as layer_error: + logger.warning("Failed to create derived layer %s: %s", layer.get("name"), layer_error) + refresh_runtime_counts() + except Exception as resample_error: + logger.warning("Derived FX layers no disponibles: %s", resample_error) + + try: + bus_result = apply_mix_bus_architecture(ableton, config) + if bus_result: + refresh_runtime_counts() + except Exception as bus_error: + bus_result = f"Mix buses no disponibles: {bus_error}" + + try: + master_result = apply_master_chain(ableton, config) + except Exception as master_error: + master_result = f"Master chain no disponible: {master_error}" + + if actual_tracks is not None: + parts.append(f"Tracks reales: {actual_tracks}") + if actual_scenes is not None: + parts.append(f"Scenes reales: {actual_scenes}") + if actual_returns is not None: + parts.append(f"Returns reales: {actual_returns}") + if actual_cue_points is not None: + parts.append(f"Locators reales: {actual_cue_points}") + if actual_structure: + parts.append(f"Estructura: {actual_structure}") + parts.append(f"Playback: {playback_mode}") + if arrangement_result: + parts.append(arrangement_result) + if marker_result: + parts.append(marker_result) + if bus_result: + parts.append(bus_result) + if master_result: + parts.append(master_result) + if hybrid_result: + parts.append(hybrid_result) + + # Construir manifest de esta generación usando config real + plan materializado. + manifest = { + "timestamp": time.time(), + "genre": resolved_genre, + "style": resolved_style, + "bpm": runtime_bpm, + "key": resolved_key, + "structure_name": actual_structure, + "profile": config.get("arrangement_profile"), + "playback_mode": playback_mode, + "reference_path": reference_audio_plan.get("reference", {}).get("path") if reference_audio_plan else None, + "reference_name": reference_audio_plan.get("reference", {}).get("file_name") if reference_audio_plan else None, + "reference_device": reference_audio_plan.get("reference", {}).get("device") if reference_audio_plan else None, + "actual_runtime": { + "tracks": actual_tracks, + "scenes": actual_scenes, + "returns": actual_returns, + "cue_points": actual_cue_points, + }, + + # Config structure + "structure": config.get("structure", actual_structure), + "sections": [{"kind": s.get("kind"), "name": s.get("name"), "start": s.get("start"), "end": s.get("end")} + for s in config.get("sections", [])], + + # Section variant summary + "section_variant_summary": { + "total_sections": len(config.get("sections", []) or []), + "variants_used": { + "drum": list(set(s.get("drum_variant", "straight") for s in config.get("sections", []) or [])), + "kick": list(set(s.get("kick_variant", (s.get("drum_role_variants") or {}).get("kick", "straight")) for s in config.get("sections", []) or [])), + "clap": list(set(s.get("clap_variant", (s.get("drum_role_variants") or {}).get("clap", "straight")) for s in config.get("sections", []) or [])), + "hat_closed": list(set(s.get("hat_closed_variant", (s.get("drum_role_variants") or {}).get("hat_closed", "straight")) for s in config.get("sections", []) or [])), + "bass": list(set(s.get("bass_variant", "anchor") for s in config.get("sections", []) or [])), + "bass_bank": list(set(s.get("bass_bank_variant", s.get("bass_variant", "anchor")) for s in config.get("sections", []) or [])), + "melodic": list(set(s.get("melodic_variant", "motif") for s in config.get("sections", []) or [])), + "melodic_bank": list(set(s.get("melodic_bank_variant", s.get("melodic_variant", "motif")) for s in config.get("sections", []) or [])), + "transition_fill": list(set(s.get("transition_fill", "none") for s in config.get("sections", []) or [])), + } + }, + + # Tracks blueprint + "tracks": [], + "buses": [], + "returns": [], + "muted_replaced_tracks": sorted(_expected_audio_replacement_tracks()), + + # Audio layers + "audio_layers": [], + "resample_layers": [], + } + + for track_spec in config.get("tracks", []) or []: + if not isinstance(track_spec, dict): + continue + manifest["tracks"].append({ + "name": track_spec.get("name"), + "role": track_spec.get("role"), + "type": track_spec.get("type"), + "bus": track_spec.get("bus"), + "device": track_spec.get("device"), + "color": track_spec.get("color"), + }) + + for bus_spec in config.get("buses", []) or []: + if not isinstance(bus_spec, dict): + continue + manifest["buses"].append({ + "name": bus_spec.get("name"), + "key": bus_spec.get("key"), + "type": bus_spec.get("type"), + "color": bus_spec.get("color"), + }) + + for return_spec in config.get("returns", []) or []: + if not isinstance(return_spec, dict): + continue + manifest["returns"].append({ + "name": return_spec.get("name"), + "send_key": return_spec.get("send_key"), + "color": return_spec.get("color"), + }) + + # Extraer reference_audio_plan si existe + if reference_audio_plan: + layers = reference_audio_plan.get('layers', []) + section_samples = reference_audio_plan.get('section_samples', {}) + sections = reference_audio_plan.get('sections', []) + + # Build section index to name mapping + section_names = {} + for idx, section in enumerate(sections): + if isinstance(section, dict): + section_key = f"{section.get('kind', '')}_{section.get('name', '')}" + section_names[idx] = { + "kind": section.get("kind"), + "name": section.get("name"), + "start": section.get("start"), + "end": section.get("end"), + } + + for layer in layers: + if isinstance(layer, dict): + # INFO CLAVE: detectar si este layer tiene samples diferentes por sección + layer_section_sources = {} # section_key -> source_path + + # Si el layer tiene info de samples por sección + if section_samples: + # Map layer name to role + layer_name = layer.get('name', '') + layer_role = None + + # Map layer names to variation roles + role_mapping = { + 'AUDIO PERC MAIN': 'perc', + 'AUDIO PERC ALT': 'perc_alt', + 'AUDIO TOP LOOP': 'top_loop', + 'AUDIO VOCAL SHOT': 'vocal_shot', + 'AUDIO SYNTH PEAK': 'synth_peak', + 'AUDIO ATMOS': 'atmos', + } + + layer_role = role_mapping.get(layer_name) + + # If we found a matching role, extract section samples + if layer_role: + for section_idx, section_samples_dict in section_samples.items(): + if isinstance(section_samples_dict, dict) and section_idx in section_names: + section_info = section_names[section_idx] + section_key = f"{section_info['kind']}_{section_info['name']}" + + # Get the sample for this role in this section + sample = section_samples_dict.get(layer_role) + if sample and isinstance(sample, dict): + sample_path = sample.get('path') or sample.get('file_path') + if sample_path: + layer_section_sources[section_key] = { + "source_path": sample_path, + "source_file": Path(sample_path).name, + "section_kind": section_info['kind'], + "section_name": section_info['name'], + } + + layer_info = { + "track_name": layer.get('name'), + "name": layer.get('name'), + "role": layer.get('role'), + "file_path": layer.get('file_path'), + "source_path": layer.get('file_path'), + "source_file": Path(layer.get('file_path', '')).name if layer.get('file_path') else None, + "section_sources": layer_section_sources, # NUEVO: fuentes reales por sección + } + + # Marcar si tiene variants reales + if len(layer_section_sources) > 1: + layer_info["has_real_variants"] = True + layer_info["variant_count"] = len(layer_section_sources) + + if 'RESAMPLE' in str(layer.get('name', '')): + manifest["resample_layers"].append(layer_info) + else: + manifest["audio_layers"].append(layer_info) + + # Resumen de variantes + variant_layers = [layer for layer in manifest["audio_layers"] if layer.get("has_real_variants")] + manifest["variant_summary"] = { + "total_layers_with_variants": len(variant_layers), + "variant_roles": [layer["name"] for layer in variant_layers], + "total_variants": sum(layer.get("variant_count", 0) for layer in variant_layers) + } + + if manifest["variant_summary"]["total_layers_with_variants"] >= 2: + logger.info("Generation has %d layers with real section variants: %s", + manifest["variant_summary"]["total_layers_with_variants"], + ", ".join(manifest["variant_summary"]["variant_roles"])) + + # Add transition event summary + manifest['transition_event_summary'] = _build_transition_event_summary(config) + + # Add mix automation summary + if 'mix_automation_summary' in config: + manifest['mix_automation_summary'] = config['mix_automation_summary'] + + _store_generation_manifest(manifest) + logger.info("Generation manifest stored with %d tracks, %d audio layers, %d resample layers, %d transition events", + len(manifest["tracks"]), len(manifest["audio_layers"]), len(manifest["resample_layers"]), + manifest.get('transition_event_summary', {}).get('total_events', 0)) + + # Finalizar tracking y actualizar memoria cross-generation + if hasattr(selector, 'end_generation_tracking'): + selector.end_generation_tracking() + if listener is not None and hasattr(listener, 'end_generation_tracking'): + listener.end_generation_tracking() + + return "\n".join(parts) + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error generando track: {str(e)}" + + +@mcp.tool() +def generate_song( + ctx: Context, + genre: str, + style: str = "", + bpm: float = 0, + key: str = "", + structure: str = "standard", + auto_play: bool = True +) -> str: + """ + Genera una cancion completa y organiza las scenes segun el preset elegido. + """ + track_result = generate_track(ctx, genre, style, bpm, key, structure) + if "Error" in track_result: + return track_result + + resolved_structure = structure + for line in track_result.splitlines(): + if line.startswith("Estructura:"): + resolved_structure = line.split(":", 1)[1].strip() or structure + break + + arrangement_result = arrange_song_structure(ctx, resolved_structure, exact=True) + playback_mode = "arrangement" if "Playback: arrangement" in track_result else "session" + ableton = get_ableton_connection() + try: + ableton.send_command("jump_to", {"time": 0}) + except Exception: + pass + + if auto_play: + playback_result = start_playback(ctx) + if playback_mode == "arrangement": + return "\n\n".join([track_result, arrangement_result, playback_result]) + + fire_scene_result = fire_scene(ctx, 0) + return "\n\n".join([track_result, arrangement_result, fire_scene_result, playback_result]) + + return "\n\n".join([track_result, arrangement_result]) + + +@mcp.tool() +def reset_diversity_memory(ctx: Context) -> str: + """ + Resetea la memoria de diversidad entre generaciones. + + Útil para empezar una nueva sesión sin influencia de generaciones previas. + """ + results = [] + + # Reset sample cross-generation memory + if reset_cross_generation_memory is not None: + reset_cross_generation_memory() + results.append("sample_memory_reset") + + # Reset reference listener memory + listener = get_reference_listener() + if listener is not None and hasattr(listener, "reset_cross_generation_tracking"): + listener.reset_cross_generation_tracking() + results.append("reference_memory_reset") + + # Reset pattern variant memory for MIDI + try: + from song_generator import reset_pattern_variant_memory + reset_pattern_variant_memory() + results.append("pattern_variant_memory_reset") + except ImportError: + pass + + logger.info("Cross-generation diversity memory reset: %s", ", ".join(results)) + return json.dumps({ + "status": "reset", + "components": results, + "timestamp": time.time() + }, indent=2) + + +@mcp.tool() +def arrange_song_structure(ctx: Context, structure: str = "standard", exact: bool = False) -> str: + """ + Crea o renombra scenes usando una estructura musical util para produccion. + """ + try: + ableton = get_ableton_connection() + sections = SONG_STRUCTURE_PRESETS.get(structure.lower(), SONG_STRUCTURE_PRESETS["standard"]) + + session_response = ableton.send_command("get_session_info") + if _is_error_response(session_response): + return f"Error: {session_response.get('message')}" + + current_scenes = session_response.get("result", {}).get("num_scenes", 0) + + while current_scenes < len(sections): + create_response = ableton.send_command("create_scene", {"index": -1}) + if _is_error_response(create_response): + return f"Error creando scenes: {create_response.get('message')}" + current_scenes += 1 + + while exact and current_scenes > len(sections): + delete_response = ableton.send_command("delete_scene", {"index": current_scenes - 1}) + if _is_error_response(delete_response): + return f"Error recortando scenes: {delete_response.get('message')}" + current_scenes -= 1 + + for index, (name, bars, color) in enumerate(sections): + label = f"{name} [{bars} bars]" + + rename_response = ableton.send_command("set_scene_name", { + "scene_index": index, + "name": label + }) + if _is_error_response(rename_response): + return f"Error nombrando scene {index}: {rename_response.get('message')}" + + ableton.send_command("set_scene_color", { + "scene_index": index, + "color": color + }) + + output = [f"Estructura '{structure}' aplicada ({len(sections)} scenes):"] + for index, (name, bars, _) in enumerate(sections): + output.append(f"{index}. {name} [{bars} bars]") + return "\n".join(output) + + except Exception as e: + return f"Error organizando estructura: {str(e)}" + + +@mcp.tool() +def search_samples(ctx: Context, query: str, category: str = "", limit: int = 10) -> str: + """ + Busca samples en la librería local + + Args: + query: Término de búsqueda (e.g., "kick", "bass", "hat") + category: Categoría (kick, snare, hat, bass, synth, percussion, vocal) + limit: Número máximo de resultados + """ + try: + if SampleIndex is None: + return "✗ Error: Módulo sample_index no disponible" + + sample_index = get_sample_index() + results = sample_index.search(query, category, limit) + + if not results: + return f"No se encontraron samples para '{query}'" + + output = [f"Samples encontrados para '{query}':\n"] + for i, sample in enumerate(results, 1): + output.append(f"{i}. {sample['name']} ({sample['category']})") + output.append(f" Path: {sample['path']}") + if 'key' in sample: + output.append(f" Key: {sample['key']}, BPM: {sample.get('bpm', 'N/A')}") + output.append("") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error buscando samples: {str(e)}" + + +@mcp.tool() +def create_drum_pattern( + ctx: Context, + track_index: int, + clip_index: int, + style: str = "techno", + pattern_type: str = "full", + length: float = 4.0 +) -> str: + """ + Crea un patrón de batería predefinido + + Args: + track_index: Índice del track MIDI donde crear el patrón + clip_index: Índice del clip/slot + style: Estilo (techno, house, trance, minimal) + pattern_type: Tipo de patrón (full, kick-only, hats-only, minimal) + length: Duración en beats + + Notas: + - Crea automáticamente el clip si no existe + - Usa notas MIDI estándar (C1=Kick, D1=Snare, F#1=CH, A#1=OH) + """ + try: + if SongGenerator is None: + return "✗ Error: Módulo song_generator no disponible" + + generator = get_song_generator() + notes = generator.create_drum_pattern(style, pattern_type, length) + + # Crear clip si no existe + ableton = get_ableton_connection() + + response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if _is_error_response(response): + ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if response.get("status") == "success": + return f"✓ Patrón de batería '{style}' creado ({len(notes)} notas)" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error creando patrón: {str(e)}" + + +@mcp.tool() +def create_bassline( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + style: str = "rolling", + length: float = 4.0 +) -> str: + """ + Crea una línea de bajo musical + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + style: Estilo (rolling, minimal, acid, walking, offbeat) + length: Duración en beats + """ + try: + if SongGenerator is None: + return "✗ Error: Módulo song_generator no disponible" + + generator = get_song_generator() + notes = generator.create_bassline(key, style, length) + + ableton = get_ableton_connection() + + # Crear clip + ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + # Agregar notas + response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if response.get("status") == "success": + return f"✓ Bassline '{style}' en {key} creado ({len(notes)} notas)" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error creando bassline: {str(e)}" + + +@mcp.tool() +def create_chord_progression( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + progression_type: str = "techno", + length: float = 16.0 +) -> str: + """ + Crea una progresión de acordes + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + progression_type: Tipo (techno, house, deep, minor) + length: Duración en beats (usualmente 16 = 4 compases) + """ + try: + if SongGenerator is None: + return "✗ Error: Módulo song_generator no disponible" + + generator = get_song_generator() + notes = generator.create_chord_progression(key, progression_type, length) + + ableton = get_ableton_connection() + + # Crear clip + ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + # Agregar notas + response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if response.get("status") == "success": + return f"✓ Progresión '{progression_type}' en {key} creada ({len(notes)} notas)" + else: + return f"✗ Error: {response.get('message')}" + + except Exception as e: + return f"✗ Error creando progresión: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - Sistema Avanzado de Samples +# ============================================================================ + +@mcp.tool() +def scan_sample_library( + ctx: Context, + analyze_audio: bool = False +) -> str: + """ + Escanear la librería de samples completa. + + Args: + analyze_audio: Analizar contenido de audio (más lento pero más preciso) + + Returns: + Estadísticas del escaneo + """ + try: + manager = get_sample_manager() + if not manager: + return "✗ Error: Sistema de samples no disponible" + + def progress(current, total, filename): + pct = (current / total) * 100 if total > 0 else 0 + logger.info(f"Escaneando: {pct:.1f}% - {filename}") + + stats = manager.scan_directory(analyze_audio=analyze_audio, progress_callback=progress) + + return f"""✓ Escaneo completado: +- Procesados: {stats['processed']} +- Agregados: {stats['added']} +- Actualizados: {stats['updated']} +- Errores: {stats['errors']} +- Total en librería: {stats['total_samples']}""" + + except Exception as e: + return f"✗ Error escaneando librería: {str(e)}" + + +@mcp.tool() +def get_sample_library_stats(ctx: Context) -> str: + """Obtiene estadísticas detalladas de la librería de samples""" + try: + manager = get_sample_manager() + if not manager: + return "✗ Error: Sistema de samples no disponible" + + stats = manager.get_stats() + + output = ["📊 Estadísticas de la Librería de Samples", "=" * 50] + output.append(f"Total samples: {stats['total_samples']}") + output.append(f"Tamaño total: {stats['total_size'] / (1024**2):.1f} MB") + output.append(f"Último escaneo: {stats['last_scan'] or 'Nunca'}") + + if stats['by_category']: + output.append("\nPor categoría:") + for cat, count in sorted(stats['by_category'].items(), key=lambda x: -x[1]): + output.append(f" {cat}: {count}") + + if stats['by_key']: + output.append("\nPor key:") + for key, count in sorted(stats['by_key'].items(), key=lambda x: -x[1]): + output.append(f" {key}: {count}") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error obteniendo estadísticas: {str(e)}" + + +@mcp.tool() +def advanced_search_samples( + ctx: Context, + query: str = "", + category: str = "", + sample_type: str = "", + key: str = "", + bpm: float = 0, + bpm_tolerance: int = 5, + genres: str = "", + tags: str = "", + limit: int = 20 +) -> str: + """ + Búsqueda avanzada de samples con múltiples filtros. + + Args: + query: Término de búsqueda en nombre + category: Categoría (drums, bass, synths, vocals, loops, one_shots) + sample_type: Tipo específico (kick, snare, bass, lead, pad, etc.) + key: Tonalidad musical (Am, F#m, C, etc.) + bpm: BPM objetivo (0 = ignorar) + bpm_tolerance: Tolerancia de BPM (+/-) + genres: Géneros separados por coma (techno, house, deep-house) + tags: Tags separados por coma + limit: Máximo de resultados + + Ejemplos: + - advanced_search_samples(category="drums", sample_type="kick") + - advanced_search_samples(key="Am", bpm=128, genres="techno,house") + - advanced_search_samples(query="punchy", category="drums") + """ + try: + manager = get_sample_manager() + if not manager: + return "✗ Error: Sistema de samples no disponible" + + # Parsear listas + genre_list = [g.strip() for g in genres.split(",") if g.strip()] if genres else None + tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None + bpm_val = bpm if bpm > 0 else None + + results = manager.search( + query=query, + category=category, + sample_type=sample_type, + key=key, + bpm=bpm_val, + bpm_tolerance=bpm_tolerance, + genres=genre_list, + tags=tag_list, + limit=limit + ) + + if not results: + return "No se encontraron samples con esos criterios." + + output = [f"🔍 Resultados ({len(results)}):\n"] + + for i, sample in enumerate(results, 1): + output.append(f"{i}. {sample.name}") + output.append(f" Tipo: {sample.category}/{sample.sample_type}") + info = [] + if sample.key: + info.append(f"Key: {sample.key}") + if sample.bpm: + info.append(f"BPM: {sample.bpm:.1f}") + if sample.genres: + info.append(f"Géneros: {', '.join(sample.genres[:3])}") + if info: + output.append(f" {' | '.join(info)}") + output.append(f" Path: {sample.path}") + output.append("") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error en búsqueda: {str(e)}" + + +@mcp.tool() +def select_samples_for_genre( + ctx: Context, + genre: str, + key: str = "", + bpm: float = 0 +) -> str: + """ + Selecciona automáticamente samples para un género musical. + + Args: + genre: Género (techno, house, tech-house, deep-house, trance, drum-and-bass, etc.) + key: Tonalidad preferida (auto-selecciona si vacío) + bpm: BPM preferido (auto-selecciona si 0) + + Returns: + Pack completo de samples organizados + """ + try: + selector = get_sample_selector() + if not selector: + return "✗ Error: Selector de samples no disponible" + + bpm_val = bpm if bpm > 0 else None + + group = selector.select_for_genre(genre, key or None, bpm_val) + + output = [f"🎵 Pack de Samples: {group.genre}", "=" * 50] + output.append(f"Key: {group.key} | BPM: {group.bpm}") + output.append("") + + # Drum Kit + output.append("🥁 Drum Kit:") + kit = group.drums + if kit.kick: + output.append(f" Kick: {kit.kick.name}") + if kit.snare: + output.append(f" Snare: {kit.snare.name}") + if kit.clap: + output.append(f" Clap: {kit.clap.name}") + if kit.hat_closed: + output.append(f" Hat Closed: {kit.hat_closed.name}") + if kit.hat_open: + output.append(f" Hat Open: {kit.hat_open.name}") + + # Bass + if group.bass: + output.append(f"\n🎸 Bass ({len(group.bass)} samples):") + for s in group.bass[:3]: + key_info = f" [{s.key}]" if s.key else "" + output.append(f" - {s.name}{key_info}") + + # Synths + if group.synths: + output.append(f"\n🎹 Synths ({len(group.synths)} samples):") + for s in group.synths[:3]: + key_info = f" [{s.key}]" if s.key else "" + output.append(f" - {s.name}{key_info}") + + # FX + if group.fx: + output.append(f"\n✨ FX ({len(group.fx)} samples):") + for s in group.fx[:2]: + output.append(f" - {s.name}") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error seleccionando samples: {str(e)}" + + +@mcp.tool() +def get_drum_kit_mapping( + ctx: Context, + genre: str = "techno", + variation: str = "standard" +) -> str: + """ + Obtiene un kit de batería con mapeo MIDI completo. + + Args: + genre: Género musical + variation: Variación del estilo (standard, heavy, minimal, etc.) + + Returns: + Información del kit y mapeo MIDI + """ + try: + selector = get_sample_selector() + if not selector: + return "✗ Error: Selector no disponible" + + kit = selector._select_drum_kit(genre, variation) + mapping = selector.get_midi_mapping_for_kit(kit) + + output = [f"🥁 Drum Kit: {kit.name}", "=" * 50] + + output.append("\nMapeo MIDI:") + output.append("-" * 30) + + midi_notes = { + 36: "C1 (Kick)", + 38: "D1 (Snare)", + 39: "D#1 (Clap)", + 42: "F#1 (Closed Hat)", + 46: "A#1 (Open Hat)", + 41: "F1 (Tom Low)", + 47: "B1 (Tom Mid)", + 49: "C#2 (Crash)", + 51: "D#2 (Ride)", + } + + for note, info in sorted(mapping['notes'].items()): + note_name = midi_notes.get(note, f"Note {note}") + sample_name = info['sample'] or "(vacío)" + output.append(f"{note_name}: {sample_name}") + + output.append("\nPara Drum Rack (pads 0-15):") + output.append("-" * 30) + for slot, info in sorted(mapping['drum_rack_slots'].items()): + note = info['note'] + sample = info['sample'] or "(vacío)" + output.append(f"Pad {slot:2d} (Note {note}): {sample}") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def analyze_audio_file( + ctx: Context, + file_path: str +) -> str: + """ + Analiza un archivo de audio y extrae características. + + Args: + file_path: Ruta completa al archivo de audio + + Returns: + Análisis completo del audio + """ + try: + if analyze_sample is None: + return "Error: Analizador de audio no disponible" + + if not os.path.exists(file_path): + return f"✗ Archivo no encontrado: {file_path}" + + result = analyze_sample(file_path) + + output = ["🔊 Análisis de Audio", "=" * 50] + output.append(f"Archivo: {os.path.basename(file_path)}") + output.append("") + output.append(f"BPM: {result.get('bpm') or 'No detectado'}") + output.append(f"Key: {result.get('key') or 'No detectado'} " + + f"(confianza: {result.get('key_confidence', 0):.2f})") + output.append(f"Duración: {result.get('duration', 0):.2f}s") + output.append(f"Sample Rate: {result.get('sample_rate', 0)} Hz") + output.append(f"Tipo detectado: {result.get('sample_type', 'unknown')}") + output.append("") + output.append(f"Es percusivo: {result.get('is_percussive', False)}") + output.append(f"Es armónico: {result.get('is_harmonic', False)}") + output.append("") + + genres = result.get('suggested_genres', []) + if genres: + output.append(f"Géneros sugeridos: {', '.join(genres)}") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error analizando audio: {str(e)}" + + +@mcp.tool() +def find_compatible_samples( + ctx: Context, + sample_path: str, + sample_type: str = "", + max_results: int = 10 +) -> str: + """ + Encuentra samples compatibles con uno de referencia. + + Args: + sample_path: Ruta del sample de referencia + sample_type: Filtrar por tipo específico + max_results: Máximo de resultados + + Returns: + Lista de samples compatibles con score + """ + try: + selector = get_sample_selector() + manager = get_sample_manager() + + if not selector or not manager: + return "✗ Error: Sistema de samples no disponible" + + sample = manager.get_by_path(sample_path) + if not sample: + return f"✗ Sample no encontrado en la librería: {sample_path}" + + compatible = selector.find_compatible_samples( + sample, + sample_type=sample_type, + max_results=max_results + ) + + if not compatible: + return "No se encontraron samples compatibles." + + output = [f"🔍 Samples compatibles con: {sample.name}", "=" * 50] + output.append(f"Key: {sample.key or 'N/A'} | BPM: {sample.bpm or 'N/A'}") + output.append("") + + for i, (s, score) in enumerate(compatible, 1): + bar_len = int(score * 20) + bar = "█" * bar_len + "░" * (20 - bar_len) + output.append(f"{i}. {s.name}") + output.append(f" Compatibilidad: [{bar}] {score:.1%}") + info = [] + if s.key: + info.append(f"Key: {s.key}") + if s.bpm: + info.append(f"BPM: {s.bpm:.1f}") + if info: + output.append(f" {' | '.join(info)}") + output.append("") + + return "\n".join(output) + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def suggest_key_change( + ctx: Context, + current_key: str, + direction: str = "fifth_up" +) -> str: + """ + Sugiere cambios de tonalidad armónicos. + + Args: + current_key: Key actual (ej: "Am", "F#m", "C") + direction: Tipo de cambio: + - fifth_up: Quinta arriba (más energía) + - fifth_down: Quinta abajo (más suave) + - relative: Cambio a relativo mayor/menor + - parallel: Cambio entre mayor/menor paralelo + + Returns: + Key sugerida y explicación + """ + try: + selector = get_sample_selector() + if not selector: + return "✗ Error: Selector no disponible" + + new_key = selector.suggest_key_change(current_key, direction) + + explanations = { + "fifth_up": "Quinta arriba - Añade tensión y energía", + "fifth_down": "Quinta abajo - Más suave, resolutivo", + "relative": "Relativo mayor/menor - Cambio de modo, misma armadura", + "parallel": "Paralelo mayor/menor - Mismo root, diferente modo" + } + + return f"""🎵 Cambio de Key Sugerido: + +Original: {current_key} +Sugerida: {new_key} +Tipo: {explanations.get(direction, direction)} + +Estos cambios son armónicamente coherentes y funcionan bien en transiciones de tracks.""" + + except Exception as e: + return f"✗ Error: {str(e)}" + + +@mcp.tool() +def get_sample_pack_for_project( + ctx: Context, + genre: str, + key: str = "", + bpm: float = 0 +) -> str: + """ + Obtiene un pack completo de samples para un proyecto. + + Args: + genre: Género musical + key: Tonalidad (auto-detecta si vacío) + bpm: BPM (auto-detecta si 0) + + Returns: + Pack completo con todos los elementos necesarios + """ + try: + manager = get_sample_manager() + if not manager: + return "✗ Error: Sistema de samples no disponible" + + bpm_val = bpm if bpm > 0 else None + + pack = manager.get_pack_for_genre(genre, key, bpm_val) + + output = [f"📦 Sample Pack: {genre.title()}", "=" * 50] + if key: + output.append(f"Key: {key}") + if bpm_val: + output.append(f"BPM: {bpm}") + output.append("") + + total = 0 + for category, samples in pack.items(): + if samples: + count = len(samples) + total += count + output.append(f"{category.replace('_', ' ').title()}: {count} samples") + for s in samples[:2]: # Mostrar solo 2 por categoría + key_info = f" [{s.key}]" if s.key else "" + bpm_info = f" {s.bpm:.0f}BPM" if s.bpm else "" + output.append(f" - {s.name}{key_info}{bpm_info}") + if len(samples) > 2: + output.append(f" ... y {len(samples) - 2} más") + output.append("") + + output.append(f"Total: {total} samples") + return "\n".join(output) + + except Exception as e: + return f"✗ Error: {str(e)}" + + + +# ============================================================================ +# HERRAMIENTAS MCP - QA Validation (Phase 7) +# ============================================================================ + +# Constants for QA validation +QA_AUDIO_RESAMPLE_TRACK_PREFIXES = ( + "AUDIO RESAMPLE REVERSE FX", + "AUDIO RESAMPLE RISER", + "AUDIO RESAMPLE DOWNLIFTER", + "AUDIO RESAMPLE STUTTER", +) + +QA_EXPECTED_BUS_KEYS = ("drums", "bass", "music", "vocal", "fx") + +QA_PROBLEMATIC_VOLUME_THRESHOLD_LOW = 0.3 +QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH = 0.95 +QA_EMPTY_CLIP_DETECTION_THRESHOLD = 0 +QA_VALID_MAIN_ROUTING_NAMES = {"MAIN", "MASTER", "EXT. OUT", "SENDS ONLY"} + +QA_MIN_NOTES_PER_CLIP = 1 +QA_MAX_EMPTY_MIDI_CLIPS_WARNING = 3 + +QA_CRITICAL_TRACK_ROLES = { + "kick": {"KICK", "AUDIO KICK"}, + "bass": {"BASS", "SUB BASS", "AUDIO BASS", "AUDIO BASS LOOP"}, + "clap": {"CLAP", "SNARE", "AUDIO CLAP"}, + "hat": {"HAT", "HAT CLOSED", "HAT OPEN", "AUDIO HAT"}, + "lead": {"LEAD", "SYNTH PEAK", "AUDIO SYNTH PEAK"}, + "chords": {"CHORDS", "SYNTH LOOP", "AUDIO SYNTH LOOP"}, + "atmos": {"ATMOS", "DRONE", "PAD", "AUDIO ATMOS"}, +} + +QA_EXPORT_READINESS_CHECKS = { + "master_volume_range": (0.75, 0.95), + "master_has_limiter": True, + "min_track_count": 6, + "min_bus_count": 3, + "max_clipping_tracks": 0, + "min_return_tracks": 2, + "min_audio_layers": 2, + "max_empty_tracks_ratio": 0.3, +} + +QA_ACTIONABLE_FIXES = { + "empty_midi_clip": { + "fix": "Double-click the clip to open the piano roll and add notes, or delete the empty clip", + "mcp_command": None, + }, + "bus_no_input": { + "fix": "Route tracks to this bus: select track(s) and set Output Routing to this bus", + "mcp_command": "set_track_routing", + }, + "return_no_sends": { + "fix": "Add send levels to this return: select track and adjust Send A/B/C to desired level", + "mcp_command": "set_track_send", + }, + "missing_critical_layer": { + "fix": "Regenerate the track or manually add a {role} layer (MIDI or Audio)", + "mcp_command": "generate_track", + }, + "missing_resample_layer": { + "fix": "Run audio resampling on the reference track, or check if reference analysis completed", + "mcp_command": None, + }, + "clipping_track": { + "fix": "Reduce track volume by 3-6dB and use a limiter on the master", + "mcp_command": "set_track_volume", + }, + "master_too_low": { + "fix": "Increase master volume to 0.85 for proper export level", + "mcp_command": "set_track_volume", + }, + "master_too_high": { + "fix": "Reduce master volume to 0.85 to prevent clipping on export", + "mcp_command": "set_track_volume", + }, + "no_returns": { + "fix": "Create return tracks for reverb (Space) and delay (Echo) effects", + "mcp_command": None, + }, + "insufficient_buses": { + "fix": "Create buses for drums, bass, music to enable proper mixing", + "mcp_command": "create_bus", + }, +} + +QA_DERIVED_FX_ROLE_MAP = { + "AUDIO RESAMPLE REVERSE FX": {"role": "reverse_fx", "bus": "fx", "expected_in_sections": ["build", "break"]}, + "AUDIO RESAMPLE RISER": {"role": "riser", "bus": "fx", "expected_in_sections": ["build", "intro"]}, + "AUDIO RESAMPLE DOWNLIFTER": {"role": "downlifter", "bus": "fx", "expected_in_sections": ["drop", "break"]}, + "AUDIO RESAMPLE STUTTER": {"role": "stutter", "bus": "vocal", "expected_in_sections": ["break", "drop"]}, +} + +QA_COMMON_RETURN_NAMES = { + "SPACE": {"sends": ["space"], "typical_devices": ["Hybrid Reverb", "Reverb", "Convolution"]}, + "ECHO": {"sends": ["echo"], "typical_devices": ["Echo", "Delay", "Ping Pong"]}, + "HEAT": {"sends": ["heat"], "typical_devices": ["Saturator", "Distortion"]}, + "GLUE": {"sends": ["glue"], "typical_devices": ["Glue Compressor", "Compressor"]}, + "REVERB": {"sends": ["reverb"], "typical_devices": ["Hybrid Reverb", "Reverb"]}, + "DELAY": {"sends": ["delay"], "typical_devices": ["Echo", "Delay"]}, +} + + +def _extract_bus_payload(response: Dict[str, Any]) -> List[Dict[str, Any]]: + if _is_error_response(response): + return [] + result = response.get("result", {}) + if isinstance(result, dict): + return list(result.get("buses", []) or []) + if isinstance(result, list): + return result + return [] + + +def _track_arrangement_clip_count(track: Dict[str, Any]) -> int: + try: + return int(track.get("arrangement_clip_count", 0) or 0) + except Exception: + return 0 + + +def _is_utility_track_name(track_name: str) -> bool: + normalized = _normalize_track_name(track_name) + return ( + not normalized + or "GUIDE" in normalized + or normalized.startswith("SC TRIGGER") + or normalized.startswith("REFERENCE ") + ) + + +def _expected_audio_replacement_tracks() -> Set[str]: + targets: Set[str] = set() + for names in REFERENCE_AUDIO_MUTE_MAP.values(): + for name in names: + targets.add(_normalize_track_name(name)) + return targets + + +def _is_expected_replacement_mute(track_name: str) -> bool: + normalized = _normalize_track_name(track_name) + return normalized in _expected_audio_replacement_tracks() + + +def _find_audio_replacement_sources(track_name: str) -> List[str]: + normalized = _normalize_track_name(track_name) + sources: List[str] = [] + for audio_track, target_names in REFERENCE_AUDIO_MUTE_MAP.items(): + if normalized in {_normalize_track_name(name) for name in target_names}: + matched_audio_track = _match_audio_track_template(audio_track, REFERENCE_AUDIO_MUTE_MAP) or audio_track + sources.append(matched_audio_track) + return sources + + +def _build_bus_sender_map(tracks: List[Dict[str, Any]], buses: List[Dict[str, Any]]) -> Dict[str, List[str]]: + sender_map: Dict[str, List[str]] = {} + bus_names = {_normalize_track_name(bus.get("name", "")) for bus in buses if isinstance(bus, dict)} + for bus_name in bus_names: + if bus_name: + sender_map[bus_name] = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = _normalize_track_name(track.get("name", "")) + destination = _normalize_track_name(track.get("current_output_routing", "")) + if not destination or destination not in sender_map: + continue + if track_name == destination: + continue + sender_map[destination].append(track_name) + return sender_map + + +def _qa_log_issue(issues: List[Dict[str, Any]], severity: str, category: str, message: str, details: Optional[Dict[str, Any]] = None) -> None: + """Helper para registrar problemas encontrados durante QA.""" + issue = { + "severity": severity, + "category": category, + "message": message, + "timestamp": time.time(), + } + if details: + issue["details"] = details + issues.append(issue) + log_level = logging.WARNING if severity in ("warning", "error") else logging.INFO + logger.log(log_level, f"[QA-{severity.upper()}] {category}: {message}") + + +@mcp.tool() +def validate_set(ctx: Context, check_routing: bool = True, check_gain: bool = True, check_clips: bool = True) -> str: + """ + Valida el set completo buscando problemas comunes. + + Args: + check_routing: Verificar routing de tracks + check_gain: Verificar niveles de gain staging + check_clips: Verificar clips vacios + + Returns: + JSON con el reporte de problemas encontrados + """ + issues: List[Dict[str, Any]] = [] + ableton = get_ableton_connection() + + try: + # Obtener informacion de tracks + tracks_response = ableton.send_command("get_tracks") + if _is_error_response(tracks_response): + return json.dumps({"error": tracks_response.get("message", "No se pudieron obtener tracks")}) + + tracks = _extract_tracks_payload(tracks_response) + + # 1. Verificar tracks mudos inesperados + _validate_muted_tracks(ableton, tracks, issues) + + # 2. Verificar clips vacios + if check_clips: + _validate_empty_clips(ableton, tracks, issues) + + # 3. Verificar returns inutiles + _validate_returns(ableton, issues) + + # 3.5. Verificar MIDI clips sin notas + _validate_empty_midi_clips(ableton, tracks, issues) + + # 4. Verificar routing roto + if check_routing: + _validate_routing(ableton, tracks, issues) + + # 5. Verificar gain staging + if check_gain: + _validate_gain_staging(ableton, tracks, issues) + + # Generar reporte + report = _generate_qa_report(issues, "Set Validation") + + return json.dumps(report, indent=2) + + except Exception as e: + logger.error(f"Error en validate_set: {e}") + return json.dumps({"error": str(e), "issues": issues}) + + +@mcp.tool() +def validate_audio_layers(ctx: Context, check_files: bool = True, check_positions: bool = True) -> str: + """ + Valida especificamente los tracks AUDIO RESAMPLE. + + Args: + check_files: Verificar que los archivos de audio existen + check_positions: Verificar que las posiciones son validas + + Returns: + JSON con el reporte de problemas encontrados + """ + issues: List[Dict[str, Any]] = [] + ableton = get_ableton_connection() + + try: + # Obtener tracks + tracks_response = ableton.send_command("get_tracks") + if _is_error_response(tracks_response): + return json.dumps({"error": tracks_response.get("message", "No se pudieron obtener tracks")}) + + tracks = _extract_tracks_payload(tracks_response) + + # Filtrar tracks AUDIO RESAMPLE + resample_tracks = [ + track for track in tracks + if isinstance(track, dict) and any( + str(track.get("name", "")).strip().upper().startswith(prefix) + for prefix in QA_AUDIO_RESAMPLE_TRACK_PREFIXES + ) + ] + + if not resample_tracks: + _qa_log_issue(issues, "info", "audio_layers", "No se encontraron tracks AUDIO RESAMPLE") + report = _generate_qa_report(issues, "Audio Layers Validation") + return json.dumps(report, indent=2) + + bus_response = ableton.send_command("list_buses") + buses = _extract_bus_payload(bus_response) + bus_name_by_key = {} + for bus in buses: + if not isinstance(bus, dict): + continue + bus_key = str(bus.get("bus_key", "") or "").strip().lower() + bus_name = _normalize_track_name(bus.get("name", "")) + if bus_key and bus_name: + bus_name_by_key[bus_key] = bus_name + + # Validar cada track AUDIO RESAMPLE + for track in resample_tracks: + track_index = int(track.get("index", -1)) + track_name = str(track.get("name", "UNKNOWN")) + normalized_name = _normalize_track_name(track_name) + template_name = _match_audio_track_template(normalized_name, AUDIO_TRACK_BUS_KEYS) + + # Verificar bus routing correcto + expected_bus = AUDIO_TRACK_BUS_KEYS.get(template_name) if template_name else None + if expected_bus: + try: + routing_response = ableton.send_command("get_track_routing", {"track_index": track_index}) + if not _is_error_response(routing_response): + current_output = _normalize_track_name(routing_response.get("result", {}).get("current_output_routing", "")) + expected_bus_name = bus_name_by_key.get(expected_bus, expected_bus.upper()) + if current_output not in {expected_bus_name, "MAIN", "MASTER"}: + _qa_log_issue(issues, "warning", "audio_layers_routing", + f"{track_name}: routing a '{current_output}' no coincide con bus esperado '{expected_bus_name}'", + {"track_index": track_index, "expected_bus": expected_bus_name, "current_routing": current_output}) + except Exception as e: + _qa_log_issue(issues, "warning", "audio_layers_routing", + f"{track_name}: error verificando routing: {e}") + else: + _qa_log_issue(issues, "info", "audio_layers_bus", + f"{track_name}: no tiene bus definido en AUDIO_TRACK_BUS_KEYS") + + # Verificar volumen segun perfil de mix + profile_template = _match_audio_track_template(normalized_name, AUDIO_LAYER_MIX_PROFILES) + mix_profile = AUDIO_LAYER_MIX_PROFILES.get(profile_template) if profile_template else None + if mix_profile: + expected_volume = float(mix_profile.get("volume", 0.7)) + try: + current_volume = float(track.get("volume", 0.7)) + volume_diff = abs(current_volume - expected_volume) + if volume_diff > 0.2: + _qa_log_issue(issues, "warning", "audio_layers_volume", + f"{track_name}: volumen {current_volume:.2f} difiere significativamente del perfil {expected_volume:.2f}", + {"track_index": track_index, "current_volume": current_volume, "expected_volume": expected_volume}) + except Exception: + pass + + arrangement_clips = _track_arrangement_clip_count(track) + if arrangement_clips <= QA_EMPTY_CLIP_DETECTION_THRESHOLD: + _qa_log_issue(issues, "warning", "audio_layers_clips", + f"{track_name}: no tiene clips en arrangement", + {"track_index": track_index, "arrangement_clip_count": arrangement_clips}) + + # Generar reporte + report = _generate_qa_report(issues, "Audio Layers Validation") + return json.dumps(report, indent=2) + + except Exception as e: + logger.error(f"Error en validate_audio_layers: {e}") + return json.dumps({"error": str(e), "issues": issues}) + + +@mcp.tool() +def detect_common_issues(ctx: Context) -> str: + """ + Detecta problemas frecuentes en el set actual. + + Returns: + JSON con la lista de problemas detectados y sugerencias de correccion + """ + issues: List[Dict[str, Any]] = [] + suggestions: List[Dict[str, Any]] = [] + ableton = get_ableton_connection() + + try: + # Obtener informacion general + tracks_response = ableton.send_command("get_tracks") + session_response = ableton.send_command("get_session_info") + + if _is_error_response(tracks_response) or _is_error_response(session_response): + return json.dumps({"error": "No se pudo obtener informacion del set"}) + + tracks = _extract_tracks_payload(tracks_response) + session_info = session_response.get("result", {}) + + # Detectar: Demasiados tracks mudos + muted_count = sum(1 for t in tracks if isinstance(t, dict) and t.get("mute", False)) + total_tracks = len(tracks) + if total_tracks > 0 and muted_count > total_tracks * 0.5: + _qa_log_issue(issues, "warning", "common_issues", + f"Demasiados tracks mudos: {muted_count}/{total_tracks} ({muted_count/total_tracks*100:.0f}%)", + {"muted_count": muted_count, "total_tracks": total_tracks}) + suggestions.append({ + "issue": "too_many_muted", + "suggestion": "Considera eliminar tracks mudos que no se usan o crear un preset de mute por seccion", + "command": "unmute_all_except", + }) + + # Detectar: Master muy alto o muy bajo + try: + master_response = ableton.send_command("get_track_info", {"track_type": "master", "track_index": 0}) + if not _is_error_response(master_response): + master_volume = float(master_response.get("result", {}).get("volume", 0.85)) + if master_volume > QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH: + _qa_log_issue(issues, "error", "common_issues", + f"Master volume muy alto: {master_volume:.2f} (riesgo de clipping)", + {"master_volume": master_volume}) + suggestions.append({ + "issue": "master_too_high", + "suggestion": "Reducir master a 0.85 (unity) o menos", + "command": "set_track_volume", + "params": {"track_type": "master", "track_index": 0, "volume": 0.85}, + }) + elif master_volume < QA_PROBLEMATIC_VOLUME_THRESHOLD_LOW: + _qa_log_issue(issues, "warning", "common_issues", + f"Master volume muy bajo: {master_volume:.2f}", + {"master_volume": master_volume}) + except Exception: + pass + + # Detectar: BPM extremo + bpm = float(session_info.get("tempo", 120)) + if bpm < 60 or bpm > 200: + _qa_log_issue(issues, "warning", "common_issues", + f"BPM fuera de rango tipico: {bpm}", + {"bpm": bpm}) + + # Detectar: Sin returns configurados + num_returns = int(session_info.get("num_return_tracks", 0)) + if num_returns == 0: + _qa_log_issue(issues, "info", "common_issues", + "No hay return tracks configurados - considera agregar reverb/delay para mezcla") + suggestions.append({ + "issue": "no_returns", + "suggestion": "Crear returns para efectos comunes (reverb, delay)", + }) + + # Detectar: Tracks sin nombre generico + generic_names = 0 + for track in tracks: + if isinstance(track, dict): + name = str(track.get("name", "")).strip().lower() + if not name or name in ("midi track", "audio track", "track", "new track"): + generic_names += 1 + if generic_names > 0: + _qa_log_issue(issues, "info", "common_issues", + f"{generic_names} tracks con nombres genericos", + {"generic_names_count": generic_names}) + + # Detectar: Tracks sin color (color 0 o sin definir) + uncolored = sum(1 for t in tracks if isinstance(t, dict) and int(t.get("color", 0)) == 0) + if uncolored > 0: + _qa_log_issue(issues, "info", "common_issues", + f"{uncolored} tracks sin color asignado") + + # Detectar: Solo activo en un track + soloed = [t for t in tracks if isinstance(t, dict) and t.get("solo", False)] + if len(soloed) == 1: + _qa_log_issue(issues, "warning", "common_issues", + f"Solo activo en un track: {soloed[0].get('name', 'UNKNOWN')} - posible error", + {"soloed_track": soloed[0].get("name")}) + suggestions.append({ + "issue": "single_solo", + "suggestion": "Desactivar solo o agregar mas tracks en solo", + }) + + # Generar reporte + report = _generate_qa_report(issues, "Common Issues Detection") + report["suggestions"] = suggestions + report["session_info"] = { + "bpm": bpm, + "total_tracks": total_tracks, + "muted_tracks": muted_count, + "num_returns": num_returns, + } + + return json.dumps(report, indent=2) + + except Exception as e: + logger.error(f"Error en detect_common_issues: {e}") + return json.dumps({"error": str(e), "issues": issues}) + + +@mcp.tool() +def diagnose_generated_set(ctx: Context, sections: List[Dict[str, Any]] = None) -> str: + """ + Diagnostica el set generado y retorna informacion util. + + Esta funcion analiza la estructura del set generado y proporciona + informacion diagnostica sobre tracks, buses, capas de audio y + posibles problemas de mezcla. + + Args: + sections: Lista opcional de secciones para analisis adicional + + Returns: + JSON con diagnostico detallado del set + """ + diagnosis = { + "total_tracks": 0,"bus_count": 0, + "return_count": 0, + "audio_track_count": 0, + "audio_resample_count": 0, + "empty_arrangement_tracks": [], + "muted_tracks": [], + "muted_replaced_tracks": [], + "unexpected_muted_tracks": [], + "buses_without_signal": [], + "buses_without_routes": [], + "missing_critical_layers": [], + "missing_derived_fx_layers": [], + "derived_fx_layers_status": {}, + "mixing_warnings": [], + "export_readiness": {"ready": True, "issues": []}, + "suggestions": [], + } + + ableton = get_ableton_connection() + + try: + tracks_response = ableton.send_command("get_tracks") + if _is_error_response(tracks_response): + return json.dumps({"error": tracks_response.get("message", "No se pudieron obtener tracks"), **diagnosis}) + + tracks = _extract_tracks_payload(tracks_response) + diagnosis["total_tracks"] = len(tracks) + + session_response = ableton.send_command("get_session_info") + if not _is_error_response(session_response): + diagnosis["return_count"] = int(session_response.get("result", {}).get("num_return_tracks", 0) or 0) + + bus_response = ableton.send_command("list_buses") + buses = _extract_bus_payload(bus_response) + diagnosis["bus_count"] = len(buses) + bus_names = {_normalize_track_name(bus.get("name", "")) for bus in buses if isinstance(bus, dict)} + bus_sender_map = _build_bus_sender_map(tracks, buses) + + master_volume = 0.85 + master_response = ableton.send_command("get_track_info", {"track_type": "master", "track_index": 0}) + if not _is_error_response(master_response): + master_volume = float(master_response.get("result", {}).get("volume", 0.85)) + diagnosis["master_volume"] = master_volume + + found_critical_layers = {role: False for role in QA_CRITICAL_TRACK_ROLES} + derived_fx_status = {prefix: {"found": False, "has_clips": False, "routed_correctly": False} + for prefix in QA_AUDIO_RESAMPLE_TRACK_PREFIXES} + track_names_set = set() + + for track in tracks: + if not isinstance(track, dict): + continue + + name = _normalize_track_name(track.get("name", "")) + track_index = int(track.get("index", -1)) + track_names_set.add(name) + + is_audio_resample = False + for prefix in QA_AUDIO_RESAMPLE_TRACK_PREFIXES: + if name.startswith(_normalize_track_name(prefix)): + is_audio_resample = True + diagnosis["audio_resample_count"] += 1 + derived_fx_status[prefix]["found"] = True + arrangement_clips = _track_arrangement_clip_count(track) + if arrangement_clips > 0: + derived_fx_status[prefix]["has_clips"] = True + + expected_bus_info = QA_DERIVED_FX_ROLE_MAP.get(prefix, {}) + expected_bus = expected_bus_info.get("bus", "fx") + current_routing = _normalize_track_name(track.get("current_output_routing", "")) + bus_match = any(bn in current_routing for bn in bus_names if expected_bus in bn.lower()) + if bus_match or current_routing in QA_VALID_MAIN_ROUTING_NAMES: + derived_fx_status[prefix]["routed_correctly"] = True + + if name.startswith("AUDIO ") and not is_audio_resample: + diagnosis["audio_track_count"] += 1 + + for role, role_names in QA_CRITICAL_TRACK_ROLES.items(): + if any(rn in name for rn in role_names): + found_critical_layers[role] = True + + if track.get("mute", False): + rendered_name = str(track.get("name", f"Track {track_index}")) + diagnosis["muted_tracks"].append(rendered_name) + if _is_expected_replacement_mute(rendered_name): + diagnosis["muted_replaced_tracks"].append(rendered_name) + elif not _is_utility_track_name(rendered_name): + diagnosis["unexpected_muted_tracks"].append(rendered_name) + + if (_track_arrangement_clip_count(track) <= QA_EMPTY_CLIP_DETECTION_THRESHOLD + and name not in bus_names + and not _is_utility_track_name(name)): + diagnosis["empty_arrangement_tracks"].append(str(track.get("name", f"Track {track_index}"))) + + diagnosis["derived_fx_layers_status"] = derived_fx_status + for prefix, status in derived_fx_status.items(): + if not status["found"]: + diagnosis["missing_derived_fx_layers"].append(prefix) + fix_info = QA_ACTIONABLE_FIXES.get("missing_resample_layer", {}) + diagnosis["suggestions"].append( + f"Add {prefix} layer: {fix_info.get('fix', 'Check if audio resampling completed during generation')}" + ) + elif not status["has_clips"]: + diagnosis["mixing_warnings"].append(f"Derived FX track '{prefix}' exists but has no clips") + diagnosis["suggestions"].append(f"Regenerate {prefix} audio or verify source audio for resampling") + elif not status["routed_correctly"]: + diagnosis["mixing_warnings"].append(f"Derived FX track '{prefix}' may have incorrect routing") + expected_bus = QA_DERIVED_FX_ROLE_MAP.get(prefix, {}).get("bus", "FX") + diagnosis["suggestions"].append(f"Route {prefix} to {expected_bus.upper()} bus for proper mixing") + + for bus in buses: + bus_name = _normalize_track_name(bus.get("name", "")) + senders = bus_sender_map.get(bus_name, []) + if not senders: + rendered_name = str(bus.get("name", "")) + diagnosis["buses_without_signal"].append(rendered_name) + diagnosis["buses_without_routes"].append(rendered_name) + fix_info = QA_ACTIONABLE_FIXES.get("bus_no_input", {}) + bus_key = next((k for k, v in {"DRUMS": ["drums"], "BASS": ["bass"], "MUSIC": ["music"], "VOCAL": ["vocal"], "FX": ["fx"]}.items() if bus_name in v), None) + expected_tracks = [] + if bus_key == "DRUMS": + expected_tracks = ["KICK", "CLAP", "HAT", "PERC"] + elif bus_key == "BASS": + expected_tracks = ["BASS", "SUB BASS"] + elif bus_key == "MUSIC": + expected_tracks = ["LEAD", "SYNTH", "CHORDS", "PAD"] + elif bus_key == "VOCAL": + expected_tracks = ["VOCAL", "VOCAL CHOP"] + elif bus_key == "FX": + expected_tracks = ["ATMOS", "RISER", "CRASH"] + + if expected_tracks: + diagnosis["suggestions"].append( + f"Route {', '.join(expected_tracks[:3])} tracks to {rendered_name} bus for proper mixing" + ) + else: + diagnosis["suggestions"].append( + f"Route tracks to {rendered_name} bus: {fix_info.get('fix', 'Set Output Routing on source tracks')}" ) + + for critical_name, alternatives in QA_CRITICAL_TRACK_ROLES.items(): + if not any(_normalize_track_name(option) in track_names_set for option in alternatives): + if not found_critical_layers[critical_name]: + diagnosis["missing_critical_layers"].append({ + "role": critical_name, + "suggested_track_names": list(alternatives)[:3], + "suggestion": f"Add {critical_name} layer (MIDI or Audio) for complete mix" + }) + + if diagnosis["bus_count"] < 3: + diagnosis["mixing_warnings"].append(f"Low bus count: {diagnosis['bus_count']} (expected 3-5)") + if diagnosis["audio_track_count"] == 0: + diagnosis["mixing_warnings"].append("No AUDIO tracks found - set may not be properly generated") + diagnosis["suggestions"].append("Run generate_track() to create audio layers") + + if diagnosis["audio_resample_count"] < 3: + diagnosis["mixing_warnings"].append(f"Low RESAMPLE count: {diagnosis['audio_resample_count']} (expected 3-4)") + diagnosis["suggestions"].append("Check if audio resampling completed during generation") + + if diagnosis["return_count"] < 2: + diagnosis["mixing_warnings"].append(f"Low return count: {diagnosis['return_count']} (expected 2-4)") + diagnosis["suggestions"].append("Add return tracks for reverb/delay effects") + + if diagnosis["unexpected_muted_tracks"]: + diagnosis["mixing_warnings"].append(f"{len(diagnosis['unexpected_muted_tracks'])} unexpected muted tracks") + diagnosis["suggestions"].append("Review muted tracks: " + ", ".join(diagnosis['unexpected_muted_tracks'][:3])) + + if diagnosis["empty_arrangement_tracks"]: + diagnosis["mixing_warnings"].append(f"{len(diagnosis['empty_arrangement_tracks'])} tracks without arrangement clips") + diagnosis["suggestions"].append("Check if Session-to-Arrangement commit completed") + + if diagnosis["buses_without_routes"]: + diagnosis["mixing_warnings"].append(f"Buses without routed senders: {', '.join(diagnosis['buses_without_routes'])}") + diagnosis["suggestions"].append("Route tracks to appropriate buses") + + if diagnosis["missing_critical_layers"]: + missing_str = ", ".join([layer["role"] for layer in diagnosis["missing_critical_layers"]]) + diagnosis["mixing_warnings"].append(f"Missing critical layers: {missing_str}") + diagnosis["suggestions"].append("Regenerate missing critical layers") + + ready = True + if master_volume < QA_EXPORT_READINESS_CHECKS["master_volume_range"][0]: + ready = False + diagnosis["export_readiness"]["issues"].append({ + "issue": "master_volume_low", + "message": f"Master volume too low: {master_volume:.2f}", + "suggestion": f"Increase to {QA_EXPORT_READINESS_CHECKS['master_volume_range'][0]:.2f} or higher" + }) + elif master_volume > QA_EXPORT_READINESS_CHECKS["master_volume_range"][1]: + ready = False + diagnosis["export_readiness"]["issues"].append({ + "issue": "master_volume_high", + "message": f"Master volume too high: {master_volume:.2f}", + "suggestion": f"Reduce to {QA_EXPORT_READINESS_CHECKS['master_volume_range'][1]:.2f} or lower to prevent clipping" + }) + + if diagnosis["bus_count"] < QA_EXPORT_READINESS_CHECKS["min_bus_count"]: + ready = False + diagnosis["export_readiness"]["issues"].append({ + "issue": "insufficient_buses", + "message": f"Only {diagnosis['bus_count']} buses (need {QA_EXPORT_READINESS_CHECKS['min_bus_count']}+)", + "suggestion": QA_ACTIONABLE_FIXES.get("insufficient_buses", {}).get("fix", "Create buses for drums, bass, music for proper mixing") + }) + diagnosis["suggestions"].append("Create DRUMS, BASS, MUSIC buses and route tracks to them") + + if diagnosis["total_tracks"] < QA_EXPORT_READINESS_CHECKS["min_track_count"]: + ready = False + diagnosis["export_readiness"]["issues"].append({ + "issue": "insufficient_tracks", + "message": f"Only {diagnosis['total_tracks']} tracks (need {QA_EXPORT_READINESS_CHECKS['min_track_count']}+)", + "suggestion": "Run generate_track() with more layers or add MIDI/Audio tracks manually" + }) + + if diagnosis["return_count"] < QA_EXPORT_READINESS_CHECKS.get("min_return_tracks", 2): + diagnosis["export_readiness"]["issues"].append({ + "issue": "insufficient_returns", + "message": f"Only {diagnosis['return_count']} return tracks (need {QA_EXPORT_READINESS_CHECKS.get('min_return_tracks', 2)}+)", + "suggestion": QA_ACTIONABLE_FIXES.get("no_returns", {}).get("fix", "Create return tracks for reverb and delay") + }) + + if diagnosis["audio_track_count"] < QA_EXPORT_READINESS_CHECKS.get("min_audio_layers", 2): + diagnosis["export_readiness"]["issues"].append({ + "issue": "insufficient_audio_layers", + "message": f"Only {diagnosis['audio_track_count']} audio tracks (may need more audio layers)", + "suggestion": "Run generate_track() again or add audio fallback layers" + }) + + empty_ratio = len(diagnosis["empty_arrangement_tracks"]) / max(1, diagnosis["total_tracks"]) + if empty_ratio > QA_EXPORT_READINESS_CHECKS.get("max_empty_tracks_ratio", 0.3): + diagnosis["export_readiness"]["issues"].append({ + "issue": "high_empty_tracks_ratio", + "message": f"{len(diagnosis['empty_arrangement_tracks'])} empty tracks ({empty_ratio*100:.0f}% of total)", + "suggestion": "Remove unused tracks or commit Session to Arrangement" + }) + + clipping_count = sum(1 for t in tracks if isinstance(t, dict) and float(t.get("volume", 0)) > QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH) + if clipping_count > QA_EXPORT_READINESS_CHECKS["max_clipping_tracks"]: + diagnosis["export_readiness"]["issues"].append({ + "issue": "clipping_risk", + "message": f"{clipping_count} tracks with volume > {QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH:.2f}", + "suggestion": "Reduce track volumes to prevent clipping on export" + }) + + if diagnosis["missing_critical_layers"]: + ready = False + diagnosis["export_readiness"]["issues"].append({ + "issue": "missing_critical_layers", + "message": f"Missing layers: {', '.join([layer['role'] for layer in diagnosis['missing_critical_layers']])}", + "suggestion": "Regenerate track to include missing layers" + }) + + diagnosis["export_readiness"]["ready"] = ready + + if not ready: + diagnosis["suggestions"].insert(0, "Fix export readiness issues before rendering") + + diagnosis["timestamp"] = time.time() + diagnosis["diagnosis_version"] = "2.0" + + return json.dumps(diagnosis, indent=2) + + except Exception as e: + logger.error(f"Error en diagnose_generated_set: {e}") + diagnosis["error"] = str(e) + return json.dumps(diagnosis, indent=2) + + +@mcp.tool() +def get_generation_manifest(ctx: Context) -> str: + """ + Retorna el manifest de la última generación con datos reales. + + Incluye: + - genre, style, bpm, key, structure + - referencia usada o null + - tracks blueprint + - buses/returns creados + - audio layers con sample paths exactos + - resample layers + - secciones y variantes usadas + """ + manifest = _get_stored_manifest() + + if not manifest: + return json.dumps({ + "error": "No generation manifest found. Run generate_track() first.", + "timestamp": time.time() + }, indent=2) + + return json.dumps(manifest, indent=2, default=str) + + +def _validate_muted_tracks(ableton: "AbletonConnection", tracks: List[Dict[str, Any]], issues: List[Dict[str, Any]]) -> None: + """Valida tracks mudos inesperados y detecta tracks que deberian estar activos.""" + muted_with_content = [] + muted_critical = [] + unexpected_muted = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = str(track.get("name", "")).strip().upper() + track_index = int(track.get("index", -1)) + normalized_name = _normalize_track_name(track_name) + + if track.get("mute", False): + if _is_utility_track_name(track_name): + continue + if _is_expected_replacement_mute(track_name): + continue + + clip_count = _track_arrangement_clip_count(track) + if clip_count > 0: + muted_with_content.append({ + "track_index": track_index, + "track_name": track.get("name", track_index), + "clips_count": clip_count, + }) + + for role, role_names in QA_CRITICAL_TRACK_ROLES.items(): + if any(rn in normalized_name for rn in role_names): + muted_critical.append({ + "track_index": track_index, + "track_name": track.get("name", track_index), + "role": role, + }) + break + + if not muted_with_content and clip_count > 0: + unexpected_muted.append({ + "track_index": track_index, + "track_name": track.get("name", track_index), + "suggestion": f"Unmute track '{track.get('name', track_index)}' or remove if unused", + }) + + for item in muted_with_content: + _qa_log_issue(issues, "warning", "muted_tracks", + f"Track '{item['track_name']}' is muted but has {item['clips_count']} arrangement clips", + {"track_index": item["track_index"], "track_name": item["track_name"], "clips_count": item["clips_count"], + "suggestion": "Unmute if this track should be audible, or delete clips if track is unused"}) + + for item in muted_critical: + _qa_log_issue(issues, "error", "muted_critical", + f"CRITICAL: Track '{item['track_name']}' ({item['role']}) is muted - this affects mix foundation", + {"track_index": item["track_index"], "track_name": item["track_name"], "role": item["role"], + "suggestion": f"Unmute {item['role']} track for proper mix balance"}) + + for item in unexpected_muted[:5]: + _qa_log_issue(issues, "info", "unexpected_muted", + f"Track '{item['track_name']}' is muted unexpectedly", + {"track_index": item["track_index"], "suggestion": item["suggestion"]}) + + +def _validate_empty_clips(ableton: "AbletonConnection", tracks: List[Dict[str, Any]], issues: List[Dict[str, Any]]) -> None: + """Valida tracks utiles sin contenido en Arrangement y detecta roles criticos vacios.""" + bus_response = ableton.send_command("list_buses") + bus_names = { + _normalize_track_name(bus.get("name", "")) + for bus in _extract_bus_payload(bus_response) + if isinstance(bus, dict) + } + + empty_critical_roles = {role: [] for role in QA_CRITICAL_TRACK_ROLES} + + for track in tracks: + if not isinstance(track, dict): + continue + track_index = int(track.get("index", -1)) + track_name = str(track.get("name", f"Track {track_index}")) + normalized_name = _normalize_track_name(track_name) + + if normalized_name in bus_names or _is_utility_track_name(normalized_name): + continue + + arrangement_clips = _track_arrangement_clip_count(track) + is_muted = track.get("mute", False) + + if arrangement_clips <= QA_EMPTY_CLIP_DETECTION_THRESHOLD and not is_muted: + for role, role_names in QA_CRITICAL_TRACK_ROLES.items(): + if any(rn in normalized_name for rn in role_names): + empty_critical_roles[role].append({ + "track_index": track_index, + "track_name": track_name, + "role": role, + }) + break + + is_audio_fallback = normalized_name.startswith("AUDIO") and not normalized_name.startswith("AUDIO RESAMPLE") + if not is_audio_fallback: + _qa_log_issue(issues, "warning", "empty_clips", + f"Track '{track_name}' has no arrangement clips", + {"track_index": track_index, "arrangement_clip_count": arrangement_clips, + "suggestion": "Add content or mute track if unused"}) + else: + _qa_log_issue(issues, "info", "empty_fallback_audio", + f"Audio fallback track '{track_name}' has no clips (may need regeneration)", + {"track_index": track_index, "suggestion": "Regenerate audio layers or check sample paths"}) + + for role, track_list in empty_critical_roles.items(): + if track_list: + tracks_str = ", ".join([t["track_name"] for t in track_list[:3]]) + _qa_log_issue(issues, "error", "empty_critical_role", + f"CRITICAL ROLE EMPTY: {role.upper()} track(s) have no content: {tracks_str}", + {"role": role, "tracks": track_list, + "suggestion": f"Generate content for {role} or add audio/MIDI clips to restore mix foundation"}) + + +def _validate_returns(ableton: "AbletonConnection", issues: List[Dict[str, Any]]) -> None: + """Valida return tracks inutiles y verifica sends activos.""" + try: + session_response = ableton.send_command("get_session_info") + if _is_error_response(session_response): + return + + num_returns = int(session_response.get("result", {}).get("num_return_tracks", 0)) + tracks_response = ableton.send_command("get_tracks") + if _is_error_response(tracks_response): + return + tracks = _extract_tracks_payload(tracks_response) + + for return_index in range(num_returns): + try: + return_info_response = ableton.send_command("get_track_info", { + "track_type": "return", + "track_index": return_index, + }) + if _is_error_response(return_info_response): + continue + return_info = return_info_response.get("result", {}) + return_name = str(return_info.get("name", f"Return {return_index}")).strip().upper() + + devices_response = ableton.send_command("get_devices", { + "track_type": "return", + "track_index": return_index, + }) + if _is_error_response(devices_response): + continue + devices = _extract_devices_payload(devices_response) + + _ = return_info.get("sends", []) + has_active_sends = False + sends_to_this_return = [] + + _ = _normalize_track_name(return_name) + for track in tracks: + if not isinstance(track, dict): + continue + track_sends = track.get("sends", []) + if isinstance(track_sends, list): + for send_idx, send_val in enumerate(track_sends): + try: + if float(send_val) > 0.01: + if send_idx == return_index: + has_active_sends = True + track_name = track.get("name", "?") + sends_to_this_return.append(track_name) + except (TypeError, ValueError): + pass + + if not devices and not has_active_sends: + fix_info = QA_ACTIONABLE_FIXES.get("return_no_sends", {}) + _qa_log_issue(issues, "warning", "useless_returns", + f"Return '{return_name}' has no devices and no sends from other tracks - not processing audio", + { + "return_index": return_index, + "return_name": return_name, + "suggestion": fix_info.get("fix", "Add devices or ensure other tracks send to this return"), + }) + + elif not has_active_sends and devices: + _qa_log_issue(issues, "info", "return_no_sends", + f"Return '{return_name}' has devices but no sends from other tracks", + { + "return_index": return_index, + "return_name": return_name, + "suggestion": "Set send levels on tracks to route audio to this return", + }) + + except Exception: + pass + + if num_returns == 0: + fix_info = QA_ACTIONABLE_FIXES.get("no_returns", {}) + _qa_log_issue(issues, "warning", "no_returns", + "No return tracks found - mix will lack spatial effects", + {"suggestion": fix_info.get("fix", "Create return tracks for reverb and delay effects")}) + + except Exception as e: + logger.debug(f"Error validando returns: {e}") + + +def _validate_empty_midi_clips(ableton: "AbletonConnection", tracks: List[Dict[str, Any]], issues: List[Dict[str, Any]]) -> None: + """Valida MIDI clips que existen pero no tienen notas.""" + empty_midi_clips = [] + tracks_with_empty_midi = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_index = int(track.get("index", -1)) + track_name = str(track.get("name", f"Track {track_index}")) + track_type = str(track.get("type", "")).lower() + + if track_type != "midi": + continue + if _is_utility_track_name(track_name): + continue + + clips = track.get("clips", []) + if not isinstance(clips, list): + clips = [] + + has_non_empty_clip = False + empty_clips_in_track = [] + + for clip_idx, clip in enumerate(clips): + if not isinstance(clip, dict): + continue + + clip_name = clip.get("name", f"Clip {clip_idx}") + is_playing = clip.get("is_playing", False) + has_notes = clip.get("has_notes", None) + notes_count = clip.get("notes_count", 0) + + if has_notes is False or (has_notes is None and notes_count == 0): + empty_clips_in_track.append({ + "clip_index": clip_idx, + "clip_name": clip_name, + "is_playing": is_playing, + }) + elif has_notes is True or notes_count > 0: + has_non_empty_clip = True + + if empty_clips_in_track and not has_non_empty_clip: + tracks_with_empty_midi.append({ + "track_index": track_index, + "track_name": track_name, + "empty_clips_count": len(empty_clips_in_track), + }) + + for empty_clip in empty_clips_in_track[:3]: + empty_midi_clips.append({ + "track_index": track_index, + "track_name": track_name, + "clip_index": empty_clip["clip_index"], + "clip_name": empty_clip["clip_name"], + "is_playing": empty_clip["is_playing"], + }) + + if len(tracks_with_empty_midi) > QA_MAX_EMPTY_MIDI_CLIPS_WARNING: + fix_info = QA_ACTIONABLE_FIXES.get("empty_midi_clip", {}) + _qa_log_issue(issues, "warning", "empty_midi_tracks", + f"{len(tracks_with_empty_midi)} MIDI tracks have only empty clips - no musical content", + { + "tracks": tracks_with_empty_midi[:5], + "suggestion": fix_info.get("fix", "Add notes to MIDI clips or remove empty tracks"), + }) + + for clip_info in empty_midi_clips[:QA_MAX_EMPTY_MIDI_CLIPS_WARNING]: + fix_info = QA_ACTIONABLE_FIXES.get("empty_midi_clip", {}) + _qa_log_issue(issues, "info", "empty_midi_clip", + f"MIDI clip '{clip_info['clip_name']}' on track '{clip_info['track_name']}' has no notes", + { + "track_index": clip_info["track_index"], + "clip_index": clip_info["clip_index"], + "suggestion": fix_info.get("fix", "Open piano roll and add notes"), + }) + + +def _validate_routing(ableton: "AbletonConnection", tracks: List[Dict[str, Any]], issues: List[Dict[str, Any]]) -> None: + """Valida routing roto y detecta tracks no routedos a buses esperados.""" + known_destinations = { + _normalize_track_name(track.get("name", "")) + for track in tracks + if isinstance(track, dict) + } + bus_name_by_key = {} + bus_response = ableton.send_command("list_buses") + for bus in _extract_bus_payload(bus_response): + if isinstance(bus, dict): + bus_key = str(bus.get("bus_key", "") or bus.get("key", "")).strip().lower() + bus_name = _normalize_track_name(bus.get("name", "")) + if bus_key and bus_name: + bus_name_by_key[bus_key] = bus_name + known_destinations.add(bus_name) + + tracks_with_broken_routing = [] + tracks_missing_bus_routing = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_index = int(track.get("index", -1)) + track_name = str(track.get("name", f"Track {track_index}")) + normalized_name = _normalize_track_name(track_name) + + if _is_utility_track_name(normalized_name): + continue + + expected_bus = None + for role_key, allowed_buses in BUS_ROUTING_MAP.items(): # noqa: F821 + if role_key in normalized_name.lower(): + expected_bus = allowed_buses + break + + if normalized_name.startswith("AUDIO "): + template_name = _match_audio_track_template(normalized_name, AUDIO_TRACK_BUS_KEYS) + if template_name: + expected_bus = {AUDIO_TRACK_BUS_KEYS.get(template_name, "")} + + try: + current_output = _normalize_track_name(track.get("current_output_routing", "")) + if not current_output: + routing_response = ableton.send_command("get_track_routing", {"track_index": track_index}) + if _is_error_response(routing_response): + continue + routing = routing_response.get("result", {}) + current_output = _normalize_track_name(routing.get("current_output_routing", "")) + + if not current_output or current_output in QA_VALID_MAIN_ROUTING_NAMES or "NO OUTPUT" in current_output: + if expected_bus and normalized_name.startswith("AUDIO "): + tracks_missing_bus_routing.append({ + "track_index": track_index, + "track_name": track_name, + "expected_bus": list(expected_bus)[0] if len(expected_bus) == 1 else list(expected_bus), + "current_routing": current_output or "Master", + }) + continue + + if current_output not in known_destinations: + tracks_with_broken_routing.append({ + "track_index": track_index, + "track_name": track_name, + "routing_target": current_output, + }) + _qa_log_issue(issues, "error", "broken_routing", + f"Track '{track_name}' routes to '{current_output}' which does not exist", + {"track_index": track_index, "routing_target": current_output, + "suggestion": f"Create bus '{current_output}' or route track to existing bus"}) + + except Exception as e: + _qa_log_issue(issues, "warning", "routing_check_error", + f"Could not check routing for track '{track_name}': {e}", + {"track_index": track_index}) + + for item in tracks_missing_bus_routing[:5]: + expected = item["expected_bus"] + if isinstance(expected, list): + expected_str = " or ".join(expected) + else: + expected_str = expected + _qa_log_issue(issues, "warning", "missing_bus_routing", + f"Track '{item['track_name']}' routes to {item['current_routing']} but should route to {expected_str}", + {"track_index": item["track_index"], "expected_bus": item["expected_bus"], + "current_routing": item["current_routing"], + "suggestion": f"Route track to '{expected_str}' bus for proper mixing"}) + + +def _validate_gain_staging(ableton: "AbletonConnection", tracks: List[Dict[str, Any]], issues: List[Dict[str, Any]]) -> None: + """Valida gain staging problematico con umbrales por tipo de track.""" + clipping_tracks = [] + quiet_tracks = [] + pan_extreme_tracks = [] + + VOLUME_THRESHOLDS_BY_TRACK = { + "KICK": {"max": 0.95, "min": 0.70}, + "BASS": {"max": 0.92, "min": 0.65}, + "CLAP": {"max": 0.88, "min": 0.55}, + "SNARE": {"max": 0.88, "min": 0.55}, + "HAT": {"max": 0.78, "min": 0.45}, + "AUDIO KICK": {"max": 0.95, "min": 0.80}, + "AUDIO CLAP": {"max": 0.85, "min": 0.65}, + "AUDIO HAT": {"max": 0.75, "min": 0.50}, + "AUDIO BASS": {"max": 0.90, "min": 0.70}, + "AUDIO BASS LOOP": {"max": 0.90, "min": 0.70}, + "AUDIO SYNTH": {"max": 0.82, "min": 0.45}, + "AUDIO VOCAL": {"max": 0.85, "min": 0.50}, + "AUDIO ATMOS": {"max": 0.70, "min": 0.35}, + "AUDIO RESAMPLE": {"max": 0.75, "min": 0.45}, + } + + for track in tracks: + if not isinstance(track, dict): + continue + track_index = int(track.get("index", -1)) + track_name = str(track.get("name", f"Track {track_index}")) + normalized_name = _normalize_track_name(track_name) + if _is_utility_track_name(track_name): + continue + if normalized_name.startswith("DRUMS") or normalized_name.startswith("BASS") or normalized_name.startswith("MUSIC") or normalized_name.startswith("VOCAL") or normalized_name.startswith("FX"): + continue + + volume = float(track.get("volume", 0.85)) + thresholds = None + for key, thresh in VOLUME_THRESHOLDS_BY_TRACK.items(): + if key in normalized_name: + thresholds = thresh + break + + if thresholds is None: + max_vol = QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH + min_vol = QA_PROBLEMATIC_VOLUME_THRESHOLD_LOW + else: + max_vol = thresholds.get("max", QA_PROBLEMATIC_VOLUME_THRESHOLD_HIGH) + min_vol = thresholds.get("min", QA_PROBLEMATIC_VOLUME_THRESHOLD_LOW) + + if volume > max_vol: + clipping_tracks.append({ + "track_index": track_index, + "track_name": track_name, + "volume": volume, + "threshold": max_vol, + }) + + if volume < min_vol and not track.get("mute", False): + quiet_tracks.append({ + "track_index": track_index, + "track_name": track_name, + "volume": volume, + "threshold": min_vol, + }) + + pan = float(track.get("pan", 0.0)) + if abs(pan) > 0.9: + pan_extreme_tracks.append({ + "track_index": track_index, + "track_name": track_name, + "pan": pan, + }) + + for item in clipping_tracks[:5]: + _qa_log_issue(issues, "error", "gain_staging", + f"Track '{item['track_name']}' volume too high: {item['volume']:.2f} (max {item['threshold']:.2f}) - CLIPPING RISK", + {"track_index": item["track_index"], "volume": item["volume"], "threshold": item["threshold"], + "suggestion": f"Reduce volume to {item['threshold']:.2f} or lower to prevent clipping"}) + + for item in quiet_tracks[:5]: + _qa_log_issue(issues, "warning", "gain_staging", + f"Track '{item['track_name']}' volume too low: {item['volume']:.2f} (min {item['threshold']:.2f})", + {"track_index": item["track_index"], "volume": item["volume"], "threshold": item["threshold"], + "suggestion": f"Increase volume to at least {item['threshold']:.2f} for proper mix level"}) + + for item in pan_extreme_tracks[:3]: + _qa_log_issue(issues, "info", "gain_staging", + f"Track '{item['track_name']}' has extreme pan: {item['pan']:+.2f}", + {"track_index": item["track_index"], "pan": item["pan"], + "suggestion": "Extreme panning may cause mix balance issues in mono playback"}) + + +def _generate_qa_report(issues: List[Dict[str, Any]], validation_type: str) -> Dict[str, Any]: + """Genera un reporte QA estructurado.""" + # Contar por severidad + by_severity = {"error": 0, "warning": 0, "info": 0} + by_category: Dict[str, int] = {} + + for issue in issues: + severity = str(issue.get("severity", "info")).lower() + category = str(issue.get("category", "unknown")) + + if severity in by_severity: + by_severity[severity] += 1 + by_category[category] = by_category.get(category, 0) + 1 + + # Determinar estado general + if by_severity["error"] > 0: + status = "FAILED" + elif by_severity["warning"] > 0: + status = "WARNING" + else: + status = "PASSED" + + return { + "validation_type": validation_type, + "status": status, + "total_issues": len(issues), + "by_severity": by_severity, + "by_category": by_category, + "issues": issues, + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + + +@mcp.tool() +def reset_diversity_memory(ctx: Context) -> str: + """ + Limpia la memoria de diversidad entre generaciones. + + Esto permite que el sistema vuelva a usar familias de samples + que habían sido penalizadas por uso previo. + + Útil cuando quieres un "refresh" completo de las selecciones. + """ + try: + # Resetear memoria en sample_selector + if reset_cross_generation_memory is not None: + reset_cross_generation_memory() + + # Resetear memoria persistente en diversity_memory + try: + from diversity_memory import reset_diversity_memory as _reset_diversity_persistent + _reset_diversity_persistent() + logger.info("Memoria de diversidad persistente reseteada") + except ImportError: + logger.warning("diversity_memory no disponible, solo se reseteó memoria en RAM") + + return json.dumps({ + "status": "success", + "message": "Memoria de diversidad reseteada completamente", + "action": "reset_diversity_memory", + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + }, indent=2) + + except Exception as e: + return json.dumps({ + "status": "error", + "message": str(e), + "action": "reset_diversity_memory" + }, indent=2) + + +@mcp.tool() +def get_diversity_memory_stats(ctx: Context) -> str: + """ + Obtiene estadísticas de la memoria de diversidad. + + Returns: + JSON con: + - used_families: familias de samples usadas y conteos + - total_families: número total de familias + - generation_count: contador de generaciones + - file_location: ubicación del archivo persistente + - critical_roles: roles críticos que usan memoria + - penalty_formula: fórmula de penalización aplicada + """ + try: + stats = {} + + # Intentar obtener stats del sistema persistente + try: + from diversity_memory import get_diversity_memory_stats as _get_diversity_stats + stats = _get_diversity_stats() + logger.info("Stats de memoria obtenidas desde diversity_memory") + except ImportError: + logger.warning("diversity_memory no disponible, usando memoria en RAM") + # Fallback a memoria en RAM + from sample_selector import get_cross_generation_state + families, paths = get_cross_generation_state() + stats = { + "used_families": families, + "total_families": len(families), + "used_paths": paths, + "total_paths": len(paths), + "generation_count": "N/A (diversity_memory no disponible)", + "file_location": None, + "critical_roles": ["kick", "clap", "hat", "bass_loop", "vocal_loop", "top_loop"], + "penalty_formula": {"0 usos": 1.0, "1 uso": 0.7, "2 usos": 0.5, "3+ usos": 0.3}, + "source": "RAM (diversity_memory no disponible)" + } + + return json.dumps(stats, indent=2, default=str) + + except Exception as e: + return json.dumps({ + "status": "error", + "message": str(e), + "action": "get_diversity_memory_stats" + }, indent=2) + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + """Punto de entrada principal""" + import argparse + + parser = argparse.ArgumentParser(description="AbletonMCP-AI Server") + parser.add_argument("--port", type=int, default=0, help="Puerto para el servidor MCP (0 = auto)") + parser.add_argument("--transport", type=str, default="stdio", choices=["stdio", "sse"], help="Transporte MCP") + args = parser.parse_args() + + print("=" * 60) + print("AbletonMCP-AI Server") + print("=" * 60) + print(f"Transporte: {args.transport}") + print(f"Conectando a Ableton en: {HOST}:{DEFAULT_PORT}") + print("-" * 60) + + # Iniciar servidor MCP + mcp.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI/MCP_Server/server_v2.py b/AbletonMCP_AI/MCP_Server/server_v2.py new file mode 100644 index 0000000..6c152db --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/server_v2.py @@ -0,0 +1,1366 @@ +""" +AbletonMCP AI Server v2 - Servidor MCP robusto para generación musical +Integra FastMCP con Ableton Live 12 via socket TCP y Max for Live via UDP + +Para ejecutar: + python -m AbletonMCP_AI.MCP_Server.server_v2 + +O con uv: + uv run python -m AbletonMCP_AI.MCP_Server.server_v2 +""" + +from mcp.server.fastmcp import FastMCP, Context +import socket +import json +import logging +import sys +from dataclasses import dataclass +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any, List, Optional +from pathlib import Path +from datetime import datetime + +# Añadir el path para imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +try: +# from song_generator import SongGenerator, StyleConfig + from sample_index import SampleIndex +except ImportError as e: + print(f"Error importando módulos locales: {e}") + SongGenerator = None + SampleIndex = None + +# Configuración de logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler(Path(__file__).parent / 'server_v2.log', mode='a') + ] +) +logger = logging.getLogger("AbletonMCP-AI-v2") + +# ============================================================================ +# CONSTANTES Y CONFIGURACIÓN +# ============================================================================ + +DEFAULT_ABLETON_PORT = 9877 +DEFAULT_MAX_PORT = 9879 +MAX_HOST = "127.0.0.1" +ABLETON_HOST = "localhost" +SAMPLES_DIR = r"C:\Users\ren\embeddings\all_tracks" + +# Colores por tipo de track +TRACK_COLORS = { + 'kick': 10, # Rojo + 'snare': 20, # Verde + 'hat': 5, # Amarillo + 'clap': 45, # Naranja + 'bass': 30, # Azul + 'synth': 50, # Rosa/Magenta + 'chords': 60, # Púrpura + 'fx': 25, # Verde claro + 'vocal': 15, # Naranja oscuro +} + +# Instrucciones para el productor (contexto de IA) +PRODUCER_INSTRUCTIONS = """ +Eres AbletonMCP-AI v2, un productor musical experto integrado con Ableton Live 12 y Max for Live. +Tu objetivo es crear música electrónica profesional mediante prompts en lenguaje natural. + +CAPACIDADES PRINCIPALES: +1. Generar tracks completos con estructura profesional (Intro, Build, Drop, Break, Outro) +2. Crear patrones MIDI para diferentes géneros (Techno, House, Trance, Tech-House, etc.) +3. Seleccionar y cargar samples apropiados desde la librería local +4. Enviar rutas de samples a Max for Live para carga dinámica +5. Configurar BPM, tonalidad y estructura musical +6. Controlar transporte (play, stop, tempo) +7. Crear clips y escenas en Ableton + +HERRAMIENTAS DISPONIBLES: +- generate_song(genre, style, bpm): Genera una canción completa +- load_sample_kit(genre): Carga un kit de samples para un género +- create_pattern(instrument, pattern_type): Crea patrones MIDI +- control_transport(action): Controla reproducción +- get_session_info(): Obtiene información de la sesión + +ESTILOS SOPORTADOS: +- Techno: Industrial, Peak Time, Dub, Minimal, Acid +- House: Deep, Tech-House, Progressive, Afro, Classic 90s +- Trance: Psy, Progressive, Uplifting +- Drum & Bass: Liquid, Neuro, Jump-up, Jungle + +FLUJO DE TRABAJO: +1. Analizar el prompt del usuario para extraer género, BPM, tonalidad, mood +2. Detectar samples disponibles en la librería +3. Generar patrones MIDI característicos del género +4. Enviar comandos a Ableton via socket TCP +5. Enviar rutas de samples a Max via UDP +6. Proporcionar feedback sobre lo creado + +REGLAS: +- Siempre verifica la conexión con Ableton antes de ejecutar comandos +- Usa valores por defecto razonables si el usuario no especifica +- Organiza los tracks con colores consistentes +- Maneja errores gracefully y proporciona mensajes útiles +- Loggea todas las operaciones para debugging +""".strip() + + +# ============================================================================ +# CLASES DE CONEXIÓN +# ============================================================================ + +@dataclass +class AbletonConnection: + """Gestiona la conexión TCP con Ableton Live""" + host: str = ABLETON_HOST + port: int = DEFAULT_ABLETON_PORT + sock: Optional[socket.socket] = None + connected: bool = False + last_error: Optional[str] = None + + def connect(self, timeout: float = 5.0) -> bool: + """Conecta al Remote Script de Ableton""" + if self.connected and self.sock: + return True + + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(timeout) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(None) # Non-blocking después de conectar + self.connected = True + self.last_error = None + logger.info(f"Conectado a Ableton en {self.host}:{self.port}") + return True + except socket.timeout: + self.last_error = f"Timeout conectando a {self.host}:{self.port}" + logger.error(self.last_error) + self.sock = None + self.connected = False + return False + except Exception as e: + self.last_error = f"Error conectando a Ableton: {e}" + logger.error(self.last_error) + self.sock = None + self.connected = False + return False + + def disconnect(self): + """Desconecta de Ableton""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error desconectando: {e}") + finally: + self.sock = None + self.connected = False + logger.info("Desconectado de Ableton") + + def send_command(self, command_type: str, params: Dict[str, Any] = None, + timeout: float = 15.0) -> Dict[str, Any]: + """Envía un comando a Ableton y retorna la respuesta""" + if not self.connected and not self.connect(): + return {"status": "error", "message": "No conectado a Ableton"} + + command = { + "type": command_type, + "params": params or {} + } + + try: + logger.debug(f"Enviando comando: {command_type}") + self.sock.sendall(json.dumps(command).encode('utf-8')) + + # Recibir respuesta + self.sock.settimeout(timeout) + chunks = [] + + while True: + try: + chunk = self.sock.recv(8192) + if not chunk: + break + chunks.append(chunk) + + # Intentar parsear JSON completo + try: + data = b''.join(chunks) + response = json.loads(data.decode('utf-8')) + return response + except json.JSONDecodeError: + continue + + except socket.timeout: + logger.warning("Timeout esperando respuesta") + break + + # Respuesta incompleta + if chunks: + data = b''.join(chunks) + try: + return json.loads(data.decode('utf-8')) + except Exception: + return {"status": "error", "message": "Respuesta JSON incompleta"} + else: + return {"status": "error", "message": "No se recibió respuesta"} + + except socket.error as e: + self.connected = False + self.last_error = f"Error de socket: {e}" + logger.error(self.last_error) + return {"status": "error", "message": str(e)} + except Exception as e: + self.connected = False + self.last_error = f"Error en comunicación: {e}" + logger.error(self.last_error) + return {"status": "error", "message": str(e)} + + +@dataclass +class MaxConnection: + """Gestiona la conexión UDP con Max for Live""" + host: str = MAX_HOST + port: int = DEFAULT_MAX_PORT + sock: Optional[socket.socket] = None + + def __post_init__(self): + self._init_socket() + + def _init_socket(self): + """Inicializa el socket UDP""" + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + logger.info(f"Socket UDP inicializado para Max en {self.host}:{self.port}") + except Exception as e: + logger.error(f"Error inicializando socket UDP: {e}") + self.sock = None + + def send_message(self, message: Dict[str, Any]) -> bool: + """Envía un mensaje JSON a Max for Live via UDP""" + if not self.sock: + self._init_socket() + if not self.sock: + return False + + try: + data = json.dumps(message).encode('utf-8') + self.sock.sendto(data, (self.host, self.port)) + logger.debug(f"Mensaje enviado a Max: {message.get('type', 'unknown')}") + return True + except Exception as e: + logger.error(f"Error enviando mensaje a Max: {e}") + return False + + def send_sample_path(self, track_index: int, sample_path: str, + slot: int = 0) -> bool: + """Envía una ruta de sample a Max para cargar""" + message = { + "type": "load_sample", + "track_index": track_index, + "sample_path": sample_path, + "slot": slot, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + def send_sample_kit(self, kit: Dict[str, List[Dict]]) -> bool: + """Envía un kit completo de samples a Max""" + message = { + "type": "load_sample_kit", + "kit": kit, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + def send_command(self, command: str, params: Dict[str, Any] = None) -> bool: + """Envía un comando genérico a Max""" + message = { + "type": "command", + "command": command, + "params": params or {}, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + +# ============================================================================ +# GESTORES GLOBALES +# ============================================================================ + +_ableton_connection: Optional[AbletonConnection] = None +_max_connection: Optional[MaxConnection] = None +_sample_index: Optional['SampleIndex'] = None +_song_generator: Optional['SongGenerator'] = None + + +def get_ableton_connection() -> AbletonConnection: + """Obtiene o crea la conexión con Ableton""" + global _ableton_connection + if _ableton_connection is None: + _ableton_connection = AbletonConnection() + return _ableton_connection + + +def get_max_connection() -> MaxConnection: + """Obtiene o crea la conexión con Max""" + global _max_connection + if _max_connection is None: + _max_connection = MaxConnection() + return _max_connection + + +def get_sample_index() -> Optional['SampleIndex']: + """Obtiene o crea el índice de samples""" + global _sample_index + if _sample_index is None and SampleIndex is not None: + try: + _sample_index = SampleIndex(SAMPLES_DIR) + except Exception as e: + logger.error(f"Error cargando índice de samples: {e}") + return _sample_index + + +def get_song_generator() -> Optional['SongGenerator']: + """Obtiene o crea el generador de canciones""" + global _song_generator + if _song_generator is None and SongGenerator is not None: + _song_generator = SongGenerator() + return _song_generator + + +# ============================================================================ +# LIFESPAN DEL SERVIDOR +# ============================================================================ + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Maneja el ciclo de vida del servidor""" + try: + logger.info("=" * 60) + logger.info("AbletonMCP-AI Server v2 iniciando...") + logger.info("=" * 60) + + # Intentar conectar a Ableton + try: + ableton = get_ableton_connection() + if ableton.connect(): + logger.info("Conectado a Ableton Live") + else: + logger.warning("No se pudo conectar a Ableton (¿está abierto el script?)") + except Exception as e: + logger.warning(f"Error conectando a Ableton: {e}") + + # Inicializar conexión con Max + try: + get_max_connection() + logger.info(f"Conexión UDP con Max lista en puerto {DEFAULT_MAX_PORT}") + except Exception as e: + logger.warning(f"Error inicializando conexión con Max: {e}") + + # Inicializar índice de samples + try: + sample_index = get_sample_index() + if sample_index: + logger.info(f"Índice de samples cargado: {len(sample_index.samples)} samples") + else: + logger.warning("Índice de samples no disponible") + except Exception as e: + logger.warning(f"Error cargando índice de samples: {e}") + + # Inicializar generador de canciones + try: + song_gen = get_song_generator() + if song_gen: + logger.info("Generador de canciones listo") + else: + logger.warning("Generador de canciones no disponible") + except Exception as e: + logger.warning(f"Error inicializando generador: {e}") + + yield { + "ableton": _ableton_connection, + "max": _max_connection, + "samples": _sample_index, + "generator": _song_generator + } + + finally: + global _ableton_connection, _max_connection + if _ableton_connection: + logger.info("Desconectando de Ableton...") + _ableton_connection.disconnect() + if _max_connection and _max_connection.sock: + logger.info("Cerrando socket UDP...") + _max_connection.sock.close() + logger.info("AbletonMCP-AI Server v2 detenido") + + +# ============================================================================ +# CREAR SERVIDOR MCP +# ============================================================================ + +mcp = FastMCP( + "AbletonMCP-AI-v2", + instructions=PRODUCER_INSTRUCTIONS, + lifespan=server_lifespan +) + + +# ============================================================================ +# HERRAMIENTAS MCP - GENERACIÓN DE CANCIONES +# ============================================================================ + +@mcp.tool() +def generate_song( + ctx: Context, + genre: str = "house", + style: str = "", + bpm: float = 0, + key: str = "", + structure: str = "standard" +) -> str: + """ + Genera una canción completa con estructura profesional + + Args: + genre: Género musical (techno, house, trance, tech-house, drum-and-bass) + style: Sub-género o estilo específico (e.g., "industrial", "deep", "90s", "minimal") + bpm: BPM deseado (0 = auto-seleccionar según género) + key: Tonalidad (e.g., "Am", "F#m", "C") - vacío = auto-seleccionar + structure: Estructura del track (standard, minimal, extended) + + Returns: + Resumen de la canción generada + + Ejemplos: + generate_song("techno", "industrial", 138, "F#m") + generate_song("house", "deep", 124, "Am") + generate_song("tech-house", "groovy", 126) + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador de canciones no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return f"Error: No se pudo conectar a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" + + # Generar configuración + config = generator.generate_config(genre, style, bpm, key, structure) + + # Enviar comando a Ableton + response = ableton.send_command("generate_complete_song", { + "genre": genre, + "style": style or config.get('style', ''), + "bpm": config.get('bpm', 120), + "key": config.get('key', ''), + "structure": structure + }) + + if response.get("status") == "success": + summary = config.get("summary", "") + return f"Canción generada exitosamente!\n{summary}" + else: + return f"Error generando canción: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en generate_song") + return f"Error: {str(e)}" + + +@mcp.tool() +def load_sample_kit( + ctx: Context, + genre: str = "techno", + key: str = "", + bpm: int = 0 +) -> str: + """ + Carga un kit de samples completo para un género específico + + Args: + genre: Género musical para seleccionar samples apropiados + key: Tonalidad preferida para samples armónicos + bpm: BPM preferido para samples con tempo específico + + Returns: + Lista de samples cargados + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + max_conn = get_max_connection() + + # Obtener pack de samples + kit = sample_index.get_sample_pack(genre, key, bpm) + + # Contar samples encontrados + total_samples = sum(len(samples) for samples in kit.values()) + + if total_samples == 0: + return f"No se encontraron samples para el género '{genre}'" + + # Enviar a Max + if max_conn.send_sample_kit(kit): + # Construir resumen + lines = [f"Kit de samples para {genre} cargado:", ""] + for category, samples in kit.items(): + if samples: + lines.append(f"{category.upper()}:") + for s in samples[:2]: # Mostrar máximo 2 por categoría + lines.append(f" - {s['name']}") + if len(samples) > 2: + lines.append(f" ... y {len(samples)-2} más") + lines.append("") + lines.append(f"Total: {total_samples} samples enviados a Max") + return "\n".join(lines) + else: + return "Error enviando kit a Max for Live" + + except Exception as e: + logger.exception("Error en load_sample_kit") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_pattern( + ctx: Context, + instrument: str, + pattern_type: str = "standard", + track_index: int = -1, + clip_index: int = 0, + length: float = 4.0, + key: str = "Am", + genre: str = "techno" +) -> str: + """ + Crea un patrón MIDI para un instrumento específico + + Args: + instrument: Tipo de instrumento (kick, snare, hat, clap, bass, chords, lead, melody) + pattern_type: Tipo de patrón (standard, minimal, full, complex, simple) + track_index: Índice del track (-1 = crear nuevo) + clip_index: Índice del clip/slot + length: Duración en beats + key: Tonalidad para instrumentos melódicos + genre: Género para estilo del patrón + + Returns: + Confirmación del patrón creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + # Crear track si es necesario + if track_index < 0: + response = ableton.send_command("create_midi_track", {"index": -1}) + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + else: + return "Error creando track MIDI" + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Generar notas según instrumento + notes = [] + color = TRACK_COLORS.get(instrument.lower(), 0) + + if instrument.lower() in ['kick', 'bd', 'bass drum']: + notes = generator._create_kick_pattern(genre, pattern_type) + elif instrument.lower() in ['snare', 'sd', 'clap']: + notes = generator._create_clap_pattern(genre, pattern_type) + elif instrument.lower() in ['hat', 'hihat', 'hh']: + notes = generator._create_hat_pattern(genre, pattern_type) + elif instrument.lower() in ['perc', 'percussion']: + notes = generator._create_perc_pattern(genre, pattern_type) + elif instrument.lower() == 'bass': + notes = generator.create_bassline(key, pattern_type, length) + elif instrument.lower() in ['chords', 'chord', 'pads']: + notes = generator.create_chord_progression(key, genre, length) + elif instrument.lower() in ['lead', 'melody', 'synth']: + notes = generator.create_melody(key, 'minor', length, genre) + else: + return f"Instrumento '{instrument}' no reconocido" + + # Aplicar color al track + if color: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Patrón '{pattern_type}' para {instrument} creado en track {track_index}, clip {clip_index} ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_pattern") + return f"Error: {str(e)}" + + +@mcp.tool() +def control_transport( + ctx: Context, + action: str, + tempo: float = None +) -> str: + """ + Controla el transporte de Ableton (play, stop, tempo) + + Args: + action: Acción a ejecutar (play, stop, continue, toggle, set_tempo) + tempo: BPM a establecer (solo para action='set_tempo') + + Returns: + Confirmación de la acción + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + action = action.lower() + + if action == "play": + response = ableton.send_command("start_playback") + if response.get("status") == "success": + return "Reproducción iniciada" + elif action == "stop": + response = ableton.send_command("stop_playback") + if response.get("status") == "success": + return "Reproducción detenida" + elif action == "continue": + response = ableton.send_command("continue_playback") + if response.get("status") == "success": + return "Reproducción continuada" + elif action in ["set_tempo", "tempo", "bpm"]: + if tempo is None or tempo <= 0: + return "Error: Debes especificar un tempo válido" + response = ableton.send_command("set_tempo", {"tempo": tempo}) + if response.get("status") == "success": + return f"Tempo establecido a {tempo} BPM" + elif action == "get_tempo": + response = ableton.send_command("get_session_info") + if response.get("status") == "success": + return f"Tempo actual: {response.get('result', {}).get('tempo', 'desconocido')} BPM" + else: + return f"Acción '{action}' no reconocida. Usa: play, stop, continue, set_tempo" + + return f"Error: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en control_transport") + return f"Error: {str(e)}" + + +@mcp.tool() +def get_session_info(ctx: Context) -> str: + """ + Obtiene información completa de la sesión actual de Ableton + + Returns: + JSON con información de la sesión (tempo, tracks, estado de reproducción) + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return f"Error: No conectado a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" + + response = ableton.send_command("get_session_info") + + if response.get("status") == "success": + result = response.get("result", {}) + info_lines = [ + "Información de la sesión:", + f" Tempo: {result.get('tempo', 'N/A')} BPM", + f" Reproduciendo: {'Sí' if result.get('is_playing') else 'No'}", + f" Tracks: {result.get('num_tracks', 'N/A')}", + ] + if 'current_song_time' in result: + info_lines.append(f" Tiempo: {result.get('current_song_time')} beats") + return "\n".join(info_lines) + else: + return f"Error: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en get_session_info") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - GESTIÓN DE SAMPLES +# ============================================================================ + +@mcp.tool() +def search_samples( + ctx: Context, + query: str, + category: str = "", + limit: int = 10 +) -> str: + """ + Busca samples en la librería local + + Args: + query: Término de búsqueda (e.g., "kick", "bass", "hat") + category: Categoría (kick, snare, hat, bass, synth, percussion, vocal) + limit: Número máximo de resultados + + Returns: + Lista de samples encontrados + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + results = sample_index.search(query, category, limit) + + if not results: + return f"No se encontraron samples para '{query}'" + + output = [f"Samples encontrados para '{query}':\n"] + for i, sample in enumerate(results, 1): + output.append(f"{i}. {sample['name']} ({sample['category']})") + output.append(f" Path: {sample['path']}") + if sample.get('key'): + output.append(f" Key: {sample['key']}, BPM: {sample.get('bpm', 'N/A')}") + output.append("") + + return "\n".join(output) + + except Exception as e: + logger.exception("Error en search_samples") + return f"Error: {str(e)}" + + +@mcp.tool() +def get_random_sample( + ctx: Context, + category: str = "" +) -> str: + """ + Obtiene un sample aleatorio de la librería + + Args: + category: Categoría opcional para filtrar + + Returns: + Información del sample seleccionado + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + sample = sample_index.get_random_sample(category) + + if not sample: + return f"No hay samples disponibles{' en categoría ' + category if category else ''}" + + return f"""Sample aleatorio seleccionado: +Nombre: {sample['name']} +Categoría: {sample['category']} +Path: {sample['path']} +Key: {sample.get('key', 'N/A')} +BPM: {sample.get('bpm', 'N/A')}""" + + except Exception as e: + logger.exception("Error en get_random_sample") + return f"Error: {str(e)}" + + +@mcp.tool() +def send_sample_to_max( + ctx: Context, + sample_path: str, + track_index: int = 0, + slot: int = 0 +) -> str: + """ + Envía una ruta de sample a Max for Live para cargar + + Args: + sample_path: Ruta completa del archivo de audio + track_index: Índice del track donde cargar + slot: Slot/clip donde cargar el sample + + Returns: + Confirmación del envío + """ + try: + max_conn = get_max_connection() + + if max_conn.send_sample_path(track_index, sample_path, slot): + return f"Sample enviado a Max: {Path(sample_path).name} -> Track {track_index}, Slot {slot}" + else: + return "Error enviando sample a Max" + + except Exception as e: + logger.exception("Error en send_sample_to_max") + return f"Error: {str(e)}" + + +@mcp.tool() +def refresh_sample_index(ctx: Context) -> str: + """ + Refresca el índice de samples escaneando el directorio nuevamente + + Returns: + Confirmación con el número de samples encontrados + """ + try: + global _sample_index + if SampleIndex is None: + return "Error: Módulo SampleIndex no disponible" + + _sample_index = SampleIndex(SAMPLES_DIR) + _sample_index.refresh() + + return f"Índice refrescado: {len(_sample_index.samples)} samples encontrados" + + except Exception as e: + logger.exception("Error en refresh_sample_index") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - CREACIÓN AVANZADA +# ============================================================================ + +@mcp.tool() +def create_drum_pattern( + ctx: Context, + track_index: int, + clip_index: int, + style: str = "techno", + pattern_type: str = "full", + length: float = 4.0 +) -> str: + """ + Crea un patrón de batería completo + + Args: + track_index: Índice del track MIDI donde crear el patrón + clip_index: Índice del clip/slot + style: Estilo (techno, house, trance, minimal) + pattern_type: Tipo de patrón (full, kick-only, hats-only, minimal) + length: Duración en beats + + Returns: + Confirmación del patrón creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_drum_pattern(style, pattern_type, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Patrón de batería '{style}' creado ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_drum_pattern") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_bassline( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + style: str = "rolling", + length: float = 16.0 +) -> str: + """ + Crea una línea de bajo musical + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + style: Estilo (rolling, minimal, acid, walking, offbeat) + length: Duración en beats + + Returns: + Confirmación del bassline creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_bassline(key, style, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Bassline '{style}' en {key} creado ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_bassline") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_chord_progression( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + progression_type: str = "techno", + length: float = 16.0 +) -> str: + """ + Crea una progresión de acordes + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + progression_type: Tipo (techno, house, deep, minor) + length: Duración en beats (usualmente 16 = 4 compases) + + Returns: + Confirmación de la progresión creada + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_chord_progression(key, progression_type, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Progresión '{progression_type}' en {key} creada ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_chord_progression") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - GESTIÓN DE TRACKS Y CLIPS +# ============================================================================ + +@mcp.tool() +def create_midi_track( + ctx: Context, + name: str = "MIDI Track", + color: int = None +) -> str: + """ + Crea un nuevo track MIDI + + Args: + name: Nombre del track + color: Color del track (0-69, opcional) + + Returns: + Confirmación con el índice del track creado + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("create_midi_track", {"index": -1}) + + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + + # Setear nombre + ableton.send_command("set_track_name", { + "track_index": track_index, + "name": name + }) + + # Setear color si se especificó + if color is not None: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + return f"Track MIDI '{name}' creado en índice {track_index}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en create_midi_track") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_audio_track( + ctx: Context, + name: str = "Audio Track", + color: int = None +) -> str: + """ + Crea un nuevo track de audio + + Args: + name: Nombre del track + color: Color del track (0-69, opcional) + + Returns: + Confirmación con el índice del track creado + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("create_audio_track", {"index": -1}) + + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + + # Setear nombre + ableton.send_command("set_track_name", { + "track_index": track_index, + "name": name + }) + + # Setear color si se especificó + if color is not None: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + return f"Track de audio '{name}' creado en índice {track_index}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en create_audio_track") + return f"Error: {str(e)}" + + +@mcp.tool() +def set_track_volume( + ctx: Context, + track_index: int, + volume: float +) -> str: + """ + Ajusta el volumen de un track (0.0 - 1.0) + + Args: + track_index: Índice del track + volume: Volumen entre 0.0 y 1.0 + + Returns: + Confirmación del cambio + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": volume + }) + + if response.get("status") == "success": + return f"Volumen del track {track_index} ajustado a {volume:.2f}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en set_track_volume") + return f"Error: {str(e)}" + + +@mcp.tool() +def fire_clip( + ctx: Context, + track_index: int, + clip_index: int +) -> str: + """ + Dispara/reproduce un clip específico + + Args: + track_index: Índice del track + clip_index: Índice del clip/slot + + Returns: + Confirmación + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("fire_clip", { + "track_index": track_index, + "clip_index": clip_index + }) + + if response.get("status") == "success": + return f"Clip en track {track_index}, slot {clip_index} disparado" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en fire_clip") + return f"Error: {str(e)}" + + +@mcp.tool() +def fire_scene( + ctx: Context, + scene_index: int +) -> str: + """ + Dispara una scene (todos sus clips) + + Args: + scene_index: Índice de la scene + + Returns: + Confirmación + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("fire_scene", { + "scene_index": scene_index + }) + + if response.get("status") == "success": + return f"Scene {scene_index} disparada" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en fire_scene") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - UTILIDADES +# ============================================================================ + +@mcp.tool() +def get_available_samples(ctx: Context) -> str: + """ + Obtiene un resumen de los samples disponibles en la librería + + Returns: + Resumen por categorías + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + categories = {} + for sample in sample_index.samples: + cat = sample['category'] + categories[cat] = categories.get(cat, 0) + 1 + + lines = ["Samples disponibles:", ""] + for cat, count in sorted(categories.items(), key=lambda x: -x[1]): + lines.append(f" {cat}: {count}") + lines.append("") + lines.append(f"Total: {len(sample_index.samples)} samples") + + return "\n".join(lines) + + except Exception as e: + logger.exception("Error en get_available_samples") + return f"Error: {str(e)}" + + +@mcp.tool() +def test_connections(ctx: Context) -> str: + """ + Prueba las conexiones con Ableton y Max + + Returns: + Estado de las conexiones + """ + results = [] + + # Probar Ableton + try: + ableton = get_ableton_connection() + if ableton.connect(timeout=3.0): + results.append("Ableton: Conectado") + # Probar comando simple + resp = ableton.send_command("get_session_info") + if resp.get("status") == "success": + results.append(f" - Tempo: {resp.get('result', {}).get('tempo')} BPM") + results.append(f" - Tracks: {resp.get('result', {}).get('num_tracks')}") + else: + results.append(f"Ableton: No conectado ({ableton.last_error})") + except Exception as e: + results.append(f"Ableton: Error - {e}") + + # Probar Max + try: + max_conn = get_max_connection() + if max_conn.send_message({"type": "ping", "timestamp": datetime.now().isoformat()}): + results.append(f"Max for Live: Conexión UDP lista en puerto {DEFAULT_MAX_PORT}") + else: + results.append("Max for Live: Error enviando mensaje") + except Exception as e: + results.append(f"Max for Live: Error - {e}") + + # Probar Samples + try: + sample_index = get_sample_index() + if sample_index: + results.append(f"Samples: {len(sample_index.samples)} samples indexados") + else: + results.append("Samples: Índice no disponible") + except Exception as e: + results.append(f"Samples: Error - {e}") + + return "\n".join(results) + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + """Punto de entrada principal""" + import argparse + + parser = argparse.ArgumentParser(description="AbletonMCP-AI Server v2") + parser.add_argument("--port", type=int, default=0, help="Puerto para el servidor MCP (0 = auto)") + parser.add_argument("--transport", type=str, default="stdio", + choices=["stdio", "sse"], help="Transporte MCP") + parser.add_argument("--test", action="store_true", help="Probar conexiones y salir") + args = parser.parse_args() + + print("=" * 60) + print("AbletonMCP-AI Server v2") + print("=" * 60) + print(f"Transporte: {args.transport}") + print(f"Ableton: {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}") + print(f"Max UDP: {MAX_HOST}:{DEFAULT_MAX_PORT}") + print(f"Samples: {SAMPLES_DIR}") + print("-" * 60) + + if args.test: + print("\nProbando conexiones...") + # Crear contexto temporal para test + ctx = Context(request_context={}) + result = test_connections(ctx) + print(result) + return + + # Iniciar servidor MCP + mcp.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI/MCP_Server/socket_smoke_test.py b/AbletonMCP_AI/MCP_Server/socket_smoke_test.py new file mode 100644 index 0000000..df16288 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/socket_smoke_test.py @@ -0,0 +1,798 @@ +import argparse +import json +import socket +from datetime import datetime +from typing import Any, Dict, List, Tuple + +try: + from song_generator import SongGenerator +except ImportError: + SongGenerator = None + + +STRUCTURE_SCENE_COUNTS = { + "minimal": 4, + "standard": 6, + "extended": 7, +} + +# Expected buses for Phase 7 validation +EXPECTED_BUSES = ["drums", "bass", "music", "vocal", "fx"] + +EXPECTED_CRITICAL_ROLES = {"kick", "bass", "clap", "hat"} + +EXPECTED_AUDIO_FX_LAYERS = ["AUDIO ATMOS", "AUDIO CRASH FX", "AUDIO TRANSITION FILL"] + +EXPECTED_BUS_NAMES = ["DRUMS", "BASS", "MUSIC"] + +MIN_TRACKS_FOR_EXPORT = 6 +MIN_BUSES_FOR_EXPORT = 3 +MIN_RETURNS_FOR_EXPORT = 2 +MASTER_VOLUME_RANGE = (0.75, 0.95) + +# Expected AUDIO RESAMPLE track names +AUDIO_RESAMPLE_TRACKS = [ + "AUDIO RESAMPLE REVERSE FX", + "AUDIO RESAMPLE RISER", + "AUDIO RESAMPLE DOWNLIFTER", + "AUDIO RESAMPLE STUTTER", +] + +# Bus routing map: track role -> expected bus output +BUS_ROUTING_MAP = { + "kick": {"drums"}, + "snare": {"drums"}, + "clap": {"drums"}, + "hat": {"drums"}, + "perc": {"drums"}, + "sub_bass": {"bass"}, + "bass": {"bass"}, + "chords": {"music"}, + "pad": {"music"}, + "pluck": {"music"}, + "lead": {"music"}, + "vocal": {"vocal"}, + "vocal_chop": {"vocal"}, + "reverse_fx": {"fx"}, + "riser": {"fx"}, + "impact": {"fx"}, + "atmos": {"fx"}, + "crash": {"drums", "fx"}, +} + + +def _extract_bus_payload(payload: Any) -> List[Dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + buses = payload.get("buses", []) + if isinstance(buses, list): + return [item for item in buses if isinstance(item, dict)] + return [] + + +def _normalize_bus_key(name: str) -> str: + normalized = "".join(ch for ch in (name or "").lower() if ch.isalnum()) + if not normalized: + return "" + if "drum" in normalized or "groove" in normalized: + return "drums" + if "bass" in normalized or "tube" in normalized or "subdeep" in normalized: + return "bass" + if "music" in normalized or "wide" in normalized: + return "music" + if "vocal" in normalized or "vox" in normalized or "tail" in normalized: + return "vocal" + if "fx" in normalized or "wash" in normalized: + return "fx" + return "" + + +def _canonical_track_name(name: str) -> str: + text = (name or "").strip().lower() + if not text: + return "" + if " (" in text: + text = text.split(" (", 1)[0].strip() + return text + + +class AbletonSocketClient: + def __init__(self, host: str = "127.0.0.1", port: int = 9877, timeout: float = 15.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + + return json.loads(line) + + +def expect_success(name: str, response: Dict[str, Any]) -> Dict[str, Any]: + if response.get("status") != "success": + raise RuntimeError(f"{name} failed: {response}") + return response.get("result", {}) + + +class TestResult: + """Tracks test results for reporting.""" + def __init__(self): + self.passed: List[Tuple[str, str]] = [] + self.failed: List[Tuple[str, str]] = [] + self.skipped: List[Tuple[str, str]] = [] + self.warnings: List[Tuple[str, str]] = [] + + def add_pass(self, name: str, details: str = ""): + self.passed.append((name, details)) + + def add_fail(self, name: str, error: str): + self.failed.append((name, error)) + + def add_skip(self, name: str, reason: str): + self.skipped.append((name, reason)) + + def add_warning(self, name: str, message: str): + self.warnings.append((name, message)) + + def to_dict(self) -> Dict[str, Any]: + return { + "summary": { + "total": len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings), + "passed": len(self.passed), + "failed": len(self.failed), + "skipped": len(self.skipped), + "warnings": len(self.warnings), + "status": "PASS" if len(self.failed) == 0 else "FAIL", + }, + "passed_tests": [{"name": n, "details": d} for n, d in self.passed], + "failed_tests": [{"name": n, "error": d} for n, d in self.failed], + "skipped_tests": [{"name": n, "reason": d} for n, d in self.skipped], + "warnings": [{"name": n, "message": d} for n, d in self.warnings], + } + + def print_report(self): + print("\n" + "=" * 60) + print("PHASE 7 SMOKE TEST REPORT") + print("=" * 60) + print(f"Timestamp: {datetime.now().isoformat()}") + print(f"Total: {len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings)}") + print(f"Passed: {len(self.passed)}") + print(f"Failed: {len(self.failed)}") + print(f"Skipped: {len(self.skipped)}") + print(f"Warnings: {len(self.warnings)}") + print("-" * 60) + + if self.passed: + print("\n[PASSED]") + for name, details in self.passed: + print(f" [OK] {name}: {details}") + + if self.failed: + print("\n[FAILED]") + for name, error in self.failed: + print(f" [FAIL] {name}: {error}") + + if self.warnings: + print("\n[WARNINGS]") + for name, message in self.warnings: + print(f" [WARN] {name}: {message}") + + if self.skipped: + print("\n[SKIPPED]") + for name, reason in self.skipped: + print(f" [SKIP] {name}: {reason}") + + print("\n" + "=" * 60) + status = "PASS" if len(self.failed) == 0 else "FAIL" + print(f"FINAL STATUS: {status}") + print("=" * 60 + "\n") + + +def run_readonly_checks(client: AbletonSocketClient) -> List[Tuple[str, str]]: + checks = [] + + expect_success("get_session_info", client.send("get_session_info")) + checks.append(( + "get_session_info", +# f"tempo={session.get('tempo')} tracks={session.get('num_tracks')} scenes={session.get('num_scenes')}", + )) + + tracks = expect_success("get_tracks", client.send("get_tracks")) + checks.append(("get_tracks", f"tracks={len(tracks)}")) + + return checks + + +def run_generation_check( + client: AbletonSocketClient, + genre: str, + style: str, + bpm: float, + key: str, + structure: str, + use_blueprint: bool = False, +) -> List[Tuple[str, str]]: + checks = [] + params = { + "genre": genre, + "style": style, + "bpm": bpm, + "key": key, + "structure": structure, + } + + if use_blueprint and SongGenerator is not None: + params = SongGenerator().generate_config(genre, style, bpm, key, structure) + + result = expect_success( + "generate_complete_song", + client.send("generate_complete_song", params), + ) + checks.append(( + "generate_complete_song", + f"tracks={result.get('tracks')} scenes={result.get('scenes')} structure={result.get('structure')}", + )) + + session = expect_success("post_generate_session_info", client.send("get_session_info")) + actual_scenes = session.get("num_scenes") + expected_scenes = len(params.get("sections", [])) if use_blueprint and isinstance(params, dict) and params.get("sections") else STRUCTURE_SCENE_COUNTS.get(structure.lower()) + if expected_scenes is not None and actual_scenes != expected_scenes: + raise RuntimeError( + f"scene count mismatch after generate_complete_song: expected {expected_scenes}, got {actual_scenes}" + ) + + checks.append(( + "post_generate_session_info", + f"tracks={session.get('num_tracks')} scenes={actual_scenes}", + )) + + return checks + + +def run_bus_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify buses are created correctly.""" + try: + buses_payload = expect_success("list_buses", client.send("list_buses")) + buses = _extract_bus_payload(buses_payload) + bus_keys = {_normalize_bus_key(bus.get("name", "")) for bus in buses} + bus_keys.discard("") + + found_buses = [] + missing_buses = [] + for expected in EXPECTED_BUSES: + if expected in bus_keys: + found_buses.append(expected) + else: + missing_buses.append(expected) + + if found_buses: + results.add_pass("buses_found", f"found={found_buses}") + + if missing_buses: + # Not a failure if buses don't exist yet - they may be created during generation + results.add_skip("buses_missing", f"not_found={missing_buses} (may be created during generation)") + else: + results.add_pass("buses_complete", "all expected buses present") + + except Exception as e: + results.add_fail("buses_check", str(e)) + + +def run_routing_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify track routing is configured correctly.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + if not tracks: + results.add_skip("routing_check", "no tracks to verify routing") + return + + correct_routing = 0 + incorrect_routing = [] + no_routing = 0 + + for track in tracks: + original_track_name = track.get("name", "") + track_name = _canonical_track_name(original_track_name) + output_routing = track.get("current_output_routing", "") + output_bus_key = _normalize_bus_key(output_routing) + track_bus_key = _normalize_bus_key(track_name) + + if output_routing and output_routing.lower() != "master": + correct_routing += 1 + elif not output_routing: + no_routing += 1 + + if track_bus_key: + continue + + for role, expected_bus in BUS_ROUTING_MAP.items(): + if role in track_name: + if output_bus_key in expected_bus: + correct_routing += 1 + elif output_routing.lower() != "master": + expected_label = "/".join(sorted(expected_bus)) + incorrect_routing.append(f"{original_track_name.lower()} -> {output_routing} (expected {expected_label})") + + results.add_pass("routing_summary", f"correct={correct_routing} no_routing={no_routing}") + + if incorrect_routing: + results.add_fail("routing_mismatches", ", ".join(incorrect_routing[:5])) + elif correct_routing > 0: + results.add_pass("routing_correct", f"{correct_routing} tracks with non-master routing") + + except Exception as e: + results.add_fail("routing_check", str(e)) + + +def run_audio_resample_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify AUDIO RESAMPLE tracks exist.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [t.get("name", "") for t in tracks] + + found_layers = [] + missing_layers = [] + + for expected in AUDIO_RESAMPLE_TRACKS: + if any(expected.upper() in name.upper() for name in track_names): + found_layers.append(expected) + else: + missing_layers.append(expected) + + if found_layers: + results.add_pass("audio_resample_found", f"layers={found_layers}") + + if missing_layers: + results.add_skip("audio_resample_missing", f"not_found={missing_layers} (may require reference audio)") + else: + results.add_pass("audio_resample_complete", "all 4 resample layers present") + + # Verify they are audio tracks + for track in tracks: + name = track.get("name", "").upper() + if "AUDIO RESAMPLE" in name: + if track.get("has_audio_input"): + results.add_pass(f"audio_track_type_{name[:20]}", "correct audio track type") + else: + results.add_fail(f"audio_track_type_{name[:20]}", "expected audio track") + + except Exception as e: + results.add_fail("audio_resample_check", str(e)) + + +def run_automation_snapshot_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify automation and device parameter snapshots.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + total_devices = 0 + tracks_with_devices = 0 + tracks_with_automation = 0 + + for track in tracks: + num_devices = track.get("num_devices", 0) + if num_devices > 0: + total_devices += num_devices + tracks_with_devices += 1 + + # Check for arrangement clips (may contain automation) + arrangement_clips = track.get("arrangement_clip_count", 0) + if arrangement_clips > 0: + tracks_with_automation += 1 + + if tracks_with_devices > 0: + results.add_pass("automation_devices", f"tracks_with_devices={tracks_with_devices} total_devices={total_devices}") + else: + results.add_skip("automation_devices", "no devices found") + + if tracks_with_automation > 0: + results.add_pass("automation_clips", f"tracks_with_arrangement_clips={tracks_with_automation}") + else: + results.add_skip("automation_clips", "no arrangement clips (may need to commit to arrangement)") + + # Try to get device parameters for first track with devices + for i, track in enumerate(tracks): + if track.get("num_devices", 0) > 0: + try: + devices = expect_success("get_devices", client.send("get_devices", {"track_index": i})) + if devices: + params_sample = [] + for dev in devices[:3]: + params = dev.get("parameters", []) + if params: + params_sample.append(f"{dev.get('name', '?')}:{len(params)}params") + if params_sample: + results.add_pass("automation_params_snapshot", ", ".join(params_sample[:3])) + break + except Exception: + pass + break + + except Exception as e: + results.add_fail("automation_snapshot_check", str(e)) + + +def run_loudness_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify basic loudness levels using output meters.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + tracks_with_signal = 0 + max_level = 0.0 + level_samples = [] + + for track in tracks: + output_level = track.get("output_meter_level", 0.0) + left = track.get("output_meter_left", 0.0) + right = track.get("output_meter_right", 0.0) + + if output_level and output_level > 0: + tracks_with_signal += 1 + max_level = max(max_level, output_level) + level_samples.append(f"{track.get('name', '?')[:15]}:{output_level:.2f}") + + # Check for stereo balance + if left and right and left > 0 and right > 0: + balance = abs(left - right) + if balance < 0.1: + pass # Balanced stereo + + if tracks_with_signal > 0: + results.add_pass("loudness_signal_detected", f"tracks_with_signal={tracks_with_signal} max_level={max_level:.3f}") + else: + results.add_skip("loudness_signal", "no signal detected (playback may be stopped)") + + # Check for clipping (levels > 1.0) + if max_level > 1.0: + results.add_fail("loudness_clipping", f"max_level={max_level:.3f} indicates potential clipping") + else: + results.add_pass("loudness_no_clipping", f"max_level={max_level:.3f}") + + # Sample levels for verification + if level_samples: + results.add_pass("loudness_levels", ", ".join(level_samples[:5])) + + except Exception as e: + results.add_fail("loudness_check", str(e)) + + +def run_critical_layer_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify critical layers (kick, bass, clap, hat) exist and have content.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] + + found_layers = {role: False for role in EXPECTED_CRITICAL_ROLES} + for track_name in track_names: + for role in EXPECTED_CRITICAL_ROLES: + if role.upper() in track_name or f"AUDIO {role.upper()}" in track_name: + found_layers[role] = True + break + + for role, found in found_layers.items(): + if found: + results.add_pass(f"critical_layer_{role}", "found in tracks") + else: + results.add_fail(f"critical_layer_{role}", "missing - set may sound incomplete") + except Exception as e: + results.add_fail("critical_layer_check", str(e)) + + +def run_derived_fx_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify derived FX tracks (AUDIO RESAMPLE) are present.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] + + found_derived = [] + missing_derived = [] + for expected in AUDIO_RESAMPLE_TRACKS: + if any(expected.upper() in name for name in track_names): + found_derived.append(expected) + else: + missing_derived.append(expected) + + if found_derived: + results.add_pass("derived_fx_found", f"layers={found_derived}") + + if missing_derived: + results.add_skip("derived_fx_missing", f"not_found={missing_derived} (may require reference audio)") + else: + results.add_pass("derived_fx_complete", "all 4 resample layers present") + + except Exception as e: + results.add_fail("derived_fx_check", str(e)) + + +def run_export_readiness_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify set is ready for export.""" + try: + expect_success("get_session_info", client.send("get_session_info")) + tracks = expect_success("get_tracks", client.send("get_tracks")) + + issues = [] + + track_count = len(tracks) if isinstance(tracks, list) else 0 + if track_count < MIN_TRACKS_FOR_EXPORT: + issues.append(f"insufficient_tracks: {track_count} (need {MIN_TRACKS_FOR_EXPORT}+)") + + master_response = client.send("get_track_info", {"track_type": "master", "track_index": 0}) + if master_response.get("status") == "success": + master_volume = float(master_response.get("result", {}).get("volume", 0.85)) + if master_volume < MASTER_VOLUME_RANGE[0]: + issues.append(f"master_volume_low: {master_volume:.2f}") + elif master_volume > MASTER_VOLUME_RANGE[1]: + issues.append(f"master_volume_high: {master_volume:.2f}") + + muted_count = sum(1 for t in tracks if isinstance(t, dict) and t.get("mute", False)) + if muted_count > track_count * 0.5: + issues.append(f"too_many_muted: {muted_count}/{track_count}") + + if issues: + results.add_pass("export_readiness_issues", f"issues={len(issues)}") + for issue in issues: + results.add_fail(f"export_ready_{issue.split(':')[0]}", issue) + else: + results.add_pass("export_ready", "set appears ready for export") + + except Exception as e: + results.add_fail("export_readiness_check", str(e)) + + +def run_midi_clip_content_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify MIDI tracks have clips with notes.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + midi_tracks_empty = [] + midi_tracks_with_notes = 0 + + for track in tracks: + if not isinstance(track, dict): + continue + track_type = str(track.get("type", "")).lower() + if track_type != "midi": + continue + + track_name = track.get("name", "?") + clips = track.get("clips", []) + if not isinstance(clips, list): + clips = [] + + has_notes = False + empty_clips = [] + for clip in clips: + if not isinstance(clip, dict): + continue + notes_count = clip.get("notes_count", 0) + has_notes_flag = clip.get("has_notes", None) + if has_notes_flag is True or notes_count > 0: + has_notes = True + elif has_notes_flag is False or (has_notes_flag is None and notes_count == 0): + empty_clips.append(clip.get("name", "?")) + if has_notes: + midi_tracks_with_notes += 1 + elif empty_clips: + midi_tracks_empty.append({ + "track_name": track_name, + "empty_clips_count": len(empty_clips), + }) + + if midi_tracks_with_notes > 0: + results.add_pass("midi_tracks_with_notes", f"count={midi_tracks_with_notes}") + + if midi_tracks_empty: + for track_info in midi_tracks_empty[:3]: + results.add_fail( + f"midi_track_empty_{track_info['track_name'][:20]}", + f"Track has {track_info['empty_clips_count']} empty MIDI clips - may need notes" + ) + + except Exception as e: + results.add_fail("midi_clip_content_check", str(e)) + + +def run_bus_signal_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify buses receive signal from tracks.""" + try: + buses_payload = expect_success("list_buses", client.send("list_buses")) + buses = _extract_bus_payload(buses_payload) + tracks = expect_success("get_tracks", client.send("get_tracks")) + + bus_signal_map = {} + for bus in buses: + if not isinstance(bus, dict): + continue + bus_name = bus.get("name", "").upper() + bus_signal_map[bus_name] = {"senders": [], "has_signal": False} + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = str(track.get("name", "")).upper() + output_routing = str(track.get("current_output_routing", "")).upper() + + for bus_name in bus_signal_map: + if bus_name in output_routing: + bus_signal_map[bus_name]["senders"].append(track_name) + + sends = track.get("sends", []) + if isinstance(sends, list): + for send_level in sends: + try: + if float(send_level) > 0.01: + pass + except (TypeError, ValueError): + pass + + buses_without_senders = [] + buses_with_senders = [] + + for bus_name, info in bus_signal_map.items(): + if info["senders"]: + buses_with_senders.append(bus_name) + else: + buses_without_senders.append(bus_name) + + if buses_with_senders: + results.add_pass("buses_with_signal", f"buses={buses_with_senders}") + + if buses_without_senders: + for bus_name in buses_without_senders[:3]: + results.add_fail(f"bus_no_signal_{bus_name[:15]}", + f"Bus '{bus_name}' has no routed tracks - will not produce output") + + except Exception as e: + results.add_fail("bus_signal_check", str(e)) + + +def run_clipping_detection(client: AbletonSocketClient, results: TestResult) -> None: + """Detect tracks with dangerously high volume (clipping risk).""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + clipping_tracks = [] + high_volume_tracks = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = track.get("name", "?") + volume = float(track.get("volume", 0.85)) + + if volume > 0.95: + clipping_tracks.append({"name": track_name, "volume": volume}) + elif volume > 0.90: + high_volume_tracks.append({"name": track_name, "volume": volume}) + + if clipping_tracks: + for track_info in clipping_tracks[:3]: + results.add_fail(f"clipping_track_{track_info['name'][:15]}",f"Volume {track_info['volume']:.2f} > 0.95 - CLIPPING RISK") + + if high_volume_tracks: + for track_info in high_volume_tracks[:3]: + results.add_warning(f"high_volume_{track_info['name'][:15]}", + f"Volume {track_info['volume']:.2f} - consider reducing") + + if not clipping_tracks and not high_volume_tracks: + results.add_pass("no_clipping_tracks", "All track volumes in safe range") + + except Exception as e: + results.add_fail("clipping_detection", str(e)) + + +def run_all_phase7_tests(client: AbletonSocketClient, results: TestResult) -> None: + """Run all Phase 7 smoke tests.""" + print("\n[Phase 7] Running bus verification...") + run_bus_checks(client, results) + + print("[Phase 7] Running routing verification...") + run_routing_checks(client, results) + + print("[Phase 7] Running AUDIO RESAMPLE track verification...") + run_audio_resample_checks(client, results) + + print("[Phase 7] Running automation snapshot verification...") + run_automation_snapshot_checks(client, results) + + print("[Phase 7] Running loudness verification...") + run_loudness_checks(client, results) + + print("[Phase 7] Running critical layer verification...") + run_critical_layer_checks(client, results) + + print("[Phase 7] Running derived FX verification...") + run_derived_fx_checks(client, results) + + print("[Phase 7] Running export readiness verification...") + run_export_readiness_checks(client, results) + + print("[Phase 7] Running MIDI clip content verification...") + run_midi_clip_content_checks(client, results) + + print("[Phase 7] Running bus signal verification...") + run_bus_signal_checks(client, results) + + print("[Phase 7] Running clipping detection...") + run_clipping_detection(client, results) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Smoke test for AbletonMCP_AI socket runtime") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9877) + parser.add_argument("--timeout", type=float, default=15.0) + parser.add_argument("--generate-demo", action="store_true") + parser.add_argument("--genre", default="techno") + parser.add_argument("--style", default="industrial") + parser.add_argument("--bpm", type=float, default=128.0) + parser.add_argument("--key", default="Am") + parser.add_argument("--structure", default="standard") + parser.add_argument("--use-blueprint", action="store_true") + parser.add_argument("--phase7", action="store_true", help="Run Phase 7 extended tests (buses, routing, audio resample, automation, loudness)") + parser.add_argument("--json-report", action="store_true", help="Output report as JSON") + args = parser.parse_args() + + client = AbletonSocketClient(host=args.host, port=args.port, timeout=args.timeout) + + # Run basic checks + print("[Basic] Running readonly checks...") + checks = run_readonly_checks(client) + + for name, details in checks: + print(f"[ok] {name}: {details}") + + # Run generation check if requested + if args.generate_demo: + print("\n[Generation] Running generation check...") + checks.extend( + run_generation_check( + client, + genre=args.genre, + style=args.style, + bpm=args.bpm, + key=args.key, + structure=args.structure, + use_blueprint=args.use_blueprint, + ) + ) + for name, details in checks[-2:]: + print(f"[ok] {name}: {details}") + + # Run Phase 7 tests if requested + results = TestResult() + if args.phase7: + run_all_phase7_tests(client, results) + + if args.json_report: + print(json.dumps(results.to_dict(), indent=2)) + else: + results.print_report() + + return 0 if len(results.failed) == 0 else 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI/MCP_Server/song_generator.py b/AbletonMCP_AI/MCP_Server/song_generator.py new file mode 100644 index 0000000..2a814f8 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/song_generator.py @@ -0,0 +1,6084 @@ +""" +song_generator.py - Generador musical para AbletonMCP-AI. +""" + +import random +import logging +from typing import List, Dict, Any, Optional, Union, Tuple +from dataclasses import dataclass +from pathlib import Path +from collections import defaultdict + +logger = logging.getLogger("SongGenerator") + +# Notas MIDI para referencia +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + +# Escalas comunes (semitonos desde la raíz) +SCALES = { + 'major': [0, 2, 4, 5, 7, 9, 11], + 'minor': [0, 2, 3, 5, 7, 8, 10], + 'harmonic_minor': [0, 2, 3, 5, 7, 8, 11], + 'dorian': [0, 2, 3, 5, 7, 9, 10], + 'phrygian': [0, 1, 3, 5, 7, 8, 10], + 'mixolydian': [0, 2, 4, 5, 7, 9, 10], + 'pentatonic_minor': [0, 3, 5, 7, 10], + 'pentatonic_major': [0, 2, 4, 7, 9], + 'blues': [0, 3, 5, 6, 7, 10], +} + +# Progresiones de acordes comunes +CHORD_PROGRESSIONS = { + 'techno': [ + [1, 1, 1, 1], # i - i - i - i (minimal) + [1, 6, 1, 6], # i - VI - i - VI + [1, 4, 1, 4], # i - iv - i - iv + [1, 7, 6, 7], # i - VII - VI - VII + ], + 'house': [ + [1, 5, 6, 4], # I - V - vi - IV (pop house) + [1, 4, 5, 1], # I - IV - V - I + [6, 4, 1, 5], # vi - IV - I - V + [1, 6, 4, 5], # I - vi - IV - V + ], + 'deep': [ + [1, 6, 2, 5], # i - VI - ii - V + [2, 5, 1, 6], # ii - V - i - VI + ], + 'trance': [ + [1, 5, 6, 4], # I - V - vi - IV + [6, 4, 1, 5], # vi - IV - I - V + [1, 4, 6, 5], # I - IV - vi - V + ], +} + +# Configuraciones por género +GENRE_CONFIGS = { + 'techno': { + 'bpm_range': (125, 140), + 'default_bpm': 132, + 'keys': ['Am', 'Fm', 'Dm', 'G#m', 'Cm'], + 'styles': ['industrial', 'peak-time', 'dub', 'minimal', 'acid'], + }, + 'house': { + 'bpm_range': (120, 128), + 'default_bpm': 124, + 'keys': ['Am', 'Em', 'Cm', 'Gm', 'Dm', 'F#m'], + 'styles': ['deep', 'tech-house', 'progressive', 'afro', 'classic', 'funky'], + }, + 'tech-house': { + 'bpm_range': (122, 128), + 'default_bpm': 125, + 'keys': ['Am', 'Fm', 'Dm', 'Gm', 'Cm'], + 'styles': ['groovy', 'bouncy', 'minimal', 'latin', 'latin-industrial'], + }, + 'trance': { + 'bpm_range': (135, 150), + 'default_bpm': 140, + 'keys': ['Fm', 'Am', 'Dm', 'Gm', 'Cm'], + 'styles': ['progressive', 'uplifting', 'psy', 'acid'], + }, + 'drum-and-bass': { + 'bpm_range': (160, 180), + 'default_bpm': 174, + 'keys': ['Am', 'Fm', 'Gm', 'Cm'], + 'styles': ['liquid', 'neuro', 'jump-up', 'jungle'], + }, +} + +# Colores por tipo de track +TRACK_COLORS = { + 'kick': 10, # Rojo + 'snare': 20, # Verde + 'hat': 5, # Amarillo + 'clap': 45, # Naranja + 'bass': 30, # Azul + 'synth': 50, # Rosa/Magenta + 'chords': 60, # Púrpura + 'fx': 25, # Verde claro + 'vocal': 15, # Naranja oscuro + 'pad': 55, # Purpura claro + 'perc': 20, # Verde + 'ride': 14, # Amarillo oscuro + 'technical': 58, # Gris +} + +BUS_TRACK_COLORS = { + 'drums': 10, + 'bass': 30, + 'music': 50, + 'vocal': 15, + 'fx': 25, + 'sc_trigger': 58, # Gris - track fantasma para sidechain +} + +# Configuracion de sidechain por bus +# Cada bus puede tener sidechain desde SC TRIGGER +BUS_SIDECHAIN_CONFIG = { + 'drums': { + 'enabled': False, # Drums no suele necesitar sidechain + 'threshold': -18.0, + 'attack': 0.003, + 'release': 0.08, + 'ratio': 4.0, + }, + 'bass': { + 'enabled': True, # Sidechain clave para bass + 'threshold': -22.0, + 'attack': 0.002, + 'release': 0.12, + 'ratio': 4.5, + }, + 'music': { + 'enabled': True, # Sidechain sutil para musica + 'threshold': -26.0, + 'attack': 0.005, + 'release': 0.18, + 'ratio': 3.0, + }, + 'vocal': { + 'enabled': True, # Sidechain suave para vocal + 'threshold': -28.0, + 'attack': 0.008, + 'release': 0.22, + 'ratio': 2.5, + }, + 'fx': { + 'enabled': False, # FX generalmente sin sidechain + 'threshold': -30.0, + 'attack': 0.01, + 'release': 0.3, + 'ratio': 2.0, + }, +} + +# ============================================================================= +# FASE 3: LOUDNESS CONSISTENCY Y GAIN STAGING +# ============================================================================= +# +# CALIBRATION PHILOSOPHY: +# ====================== +# - Kick sits at unity (0.85) as the rhythmic anchor +# - Bass sits slightly below kick (-1dB) for low-end presence without mud +# - Supporting elements progressively lower to create mix depth +# - Buses attenuated to preserve master headroom +# - Master chain with soft limiting for consistent output +# +# HEADROOM TARGETS: +# ================= +# - Track peaks: -6dB to -3dB before bus +# - Bus peaks: -3dB to -1dB before master +# - Master out: -1dB peak (limited), integrated LUFS ~-10 to -8 + +# Headroom target en dB (negativo para dejar espacio antes del limiter) +TARGET_HEADROOM_DB = -1.5 # 1.5dB de headroom antes del limiter + +# Safe limiting threshold - prevents digital clipping +MASTER_LIMITER_CEILING_DB = -0.3 # Never go above -0.3dBFS on master + +# Calibracion de ganancia por bus (valores lineales 0.0-1.0) +# Calibrado empiricamente para headroom consistente y balance de mezcla +# K: Drums como elemento principal, B: Bass como soporte, M: Music como capa +BUS_GAIN_CALIBRATION = { + 'drums': { + 'volume': 0.92, # Drums bus: principal, mas alto + 'limiter_gain': 0.0, # Sin gain adicional en limiter de bus + 'compressor_threshold': -16.0, # Compression suave para punch + 'saturator_drive': 0.6, # armonia sutil, no crunchy + 'utility_gain': 0.0, # Sin gain adicional + }, + 'bass': { + 'volume': 0.88, # Bass bus: soporte fuerte + 'limiter_gain': 0.0, # Sin limiter en bass bus (soft clip natural) + 'compressor_threshold': -18.0, # Threshold suave para low-end + 'saturator_drive': 0.4, # Saturacion sutil - evitar crunch + 'utility_gain': 0.0, # Sin gain adicional + }, + 'music': { + 'volume': 0.85, # Music bus: capa principal + 'limiter_gain': 0.0, # Sin limiter en music bus + 'compressor_threshold': -20.0, # Preservar transients + 'saturator_drive': 0.0, # Sin saturacion en bus de musica + 'utility_gain': 0.0, + }, + 'vocal': { + 'volume': 0.82, # Vocal bus: presente en mezcla + 'limiter_gain': 0.0, # Sin limiter + 'compressor_threshold': -16.0, # Compresion sutil para presencia + 'saturator_drive': 0.0, + 'utility_gain': 0.0, + }, + 'fx': { + 'volume': 0.78, # FX bus: efectos audibles + 'limiter_gain': 0.0, # Sin gain + 'compressor_threshold': -22.0, # Preservar dynamics + 'saturator_drive': 0.0, + 'utility_gain': 0.0, # Sin reduccion + }, + 'sc_trigger': { + 'volume': 0.0, # Track fantasma - sin audio + 'limiter_gain': 0.0, + 'compressor_threshold': 0.0, + 'saturator_drive': 0.0, + 'utility_gain': 0.0, + }, +} + +# Master chain calibracion +# Calibrado para LUFS ~-8 a -10dB con headroom de 1-2dB antes del limiter +# El limiter ceiling esta en -0.3dB para evitar digital clipping +MASTER_CALIBRATION = { + 'default': { + 'volume': 0.85, # Master at ~0dB de ganancia interna + 'utility_gain': 0.0, # Sin reduccion - volumen completo + 'stereo_width': 1.04, # Ligerisimo widening + 'saturator_drive': 0.12, # Saturacion muy sutil en master + 'compressor_ratio': 0.50, # Compresion suave (glue, no squash) + 'compressor_attack': 0.30, # Attack lento para preservar transients + 'compressor_release': 0.20, + 'limiter_gain': 3.5, # +3.5dB make-up gain para nivel moderno + 'limiter_ceiling': -0.3, # Ceiling a -0.3dBFS (safe limiting) + }, + 'warehouse': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'saturator_drive': 0.25, # Mas drive para industrial techno + 'compressor_ratio': 0.55, # Un poco mas de compresion + 'limiter_gain': 3.8, # Mas gain para industrial + 'limiter_ceiling': -0.3, + }, + 'festival': { + 'volume': 0.86, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.06, # Mas ancho para festival + 'limiter_gain': 4.0, # Maximo gain para festival + 'limiter_ceiling': -0.3, + }, + 'swing': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'saturator_drive': 0.15, # Moderado + 'limiter_gain': 3.2, + 'limiter_ceiling': -0.3, + }, + 'jackin': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'compressor_ratio': 0.52, + 'limiter_gain': 3.0, + 'limiter_ceiling': -0.3, + }, + 'tech-house-club': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.04, + 'saturator_drive': 0.4, # Mas drive para punch + 'compressor_ratio': 0.60, # Mas compresion para club + 'compressor_attack': 0.28, + 'limiter_gain': 3.5, + 'limiter_ceiling': -0.3, + }, + 'tech-house-deep': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.02, # Narrower para deep + 'saturator_drive': 0.1, # Muy sutil + 'compressor_ratio': 0.50, + 'compressor_attack': 0.38, # Mas lento para deep + 'limiter_gain': 3.0, + 'limiter_ceiling': -0.3, + }, + 'tech-house-funky': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'stereo_width': 1.08, # Wide para groove + 'saturator_drive': 0.3, + 'compressor_ratio': 0.55, + 'compressor_attack': 0.30, + 'limiter_gain': 3.5, + 'limiter_ceiling': -0.3, + }, +} + +# Calibracion de gain por rol para consistencia de mezcla +# Valores calibrados empiricamente basados en: +# - Kick como ancla a 0.85 +# - Bass -1dB relativo a kick +# - Elementos de soporte progresivamente mas bajos +# - Headroom preservado en cada capa +ROLE_GAIN_CALIBRATION = { + # DRUMS - Kick es el ancla, otros elementos debajo + 'kick': { + 'volume': 0.85, # Ancla: 0dB relativo, elemento principal + 'saturator_drive': 1.5, # Saturacion sutil para punch + 'peak_reduction': 0.0, # Sin reduccion - es el ancla + }, + 'clap': { + 'volume': 0.78, # -1.5dB relativo a kick + 'saturator_drive': 0.0, # Sin saturacion + 'peak_reduction': 0.0, + }, + 'snare_fill': { + 'volume': 0.72, # -3dB, transitorio fuerte + 'peak_reduction': 0.0, + }, + 'hat_closed': { + 'volume': 0.68, # -4dB, elemento secundario + 'peak_reduction': 0.0, + }, + 'hat_open': { + 'volume': 0.65, # -4.5dB, mas abajo por sustain + 'peak_reduction': 0.0, + }, + 'top_loop': { + 'volume': 0.62, # -5dB, capa ritmica secundaria + 'peak_reduction': 0.0, + }, + 'perc': { + 'volume': 0.70, # -3.5dB, soporte ritmico + 'peak_reduction': 0.0, + }, + 'ride': { + 'volume': 0.58, # -5.5dB, sustain largo + 'peak_reduction': 0.0, + }, + 'crash': { + 'volume': 0.50, # -7dB, transitorio largo + 'peak_reduction': 0.0, + }, + 'tom_fill': { + 'volume': 0.68, # -4dB, transitorio + 'peak_reduction': 0.0, + }, + # BASS - Underground but underneath drums + 'sub_bass': { + 'volume': 0.80, # -0.5dB relativo a kick + 'saturator_drive': 0.0, # Sin saturacion en sub + 'peak_reduction': 0.0, + }, + 'bass': { + 'volume': 0.78, # -1dB relativo a kick + 'saturator_drive': 2.0, # Moderado para harmonic content + 'peak_reduction': 0.0, + }, + # MUSIC - Capas de soporte, debajo del low-end + 'drone': { + 'volume': 0.55, # -7dB, elemento de fondo + 'peak_reduction': 0.0, + }, + 'chords': { + 'volume': 0.70, # -3dB, armonia principal + 'peak_reduction': 0.0, + }, + 'stab': { + 'volume': 0.65, # -4dB, transitorio + 'saturator_drive': 1.8, # Moderado + 'peak_reduction': 0.0, + }, + 'pad': { + 'volume': 0.60, # -5dB, fondo armonico + 'peak_reduction': 0.0, + }, + 'pluck': { + 'volume': 0.68, # -3.5dB, melodia sutil + 'peak_reduction': 0.0, + }, + 'arp': { + 'volume': 0.65, # -4dB, movimiento armonico + 'peak_reduction': 0.0, + }, + 'lead': { + 'volume': 0.72, # -2.5dB, elemento principal musical + 'saturator_drive': 1.2, # Moderado + 'peak_reduction': 0.0, + }, + 'counter': { + 'volume': 0.62, # -5dB, contramelodia + 'peak_reduction': 0.0, + }, + # FX - Efectos en el fondo de la mezcla + 'reverse_fx': { + 'volume': 0.52, # -7dB, efecto ambiente + 'peak_reduction': 0.0, + }, + 'riser': { + 'volume': 0.60, # -5dB, sube hacia el climax + 'peak_reduction': 0.0, + }, + 'impact': { + 'volume': 0.55, # -6dB, efecto puntual + 'peak_reduction': 0.0, + }, + 'atmos': { + 'volume': 0.50, # -8dB, fondo atmosferico + 'peak_reduction': 0.0, + }, + # VOCAL + 'vocal': { + 'volume': 0.70, # -3dB, debajo de drums pero presente + 'peak_reduction': 0.0, + }, + # SC TRIGGER - Track fantasma para sidechain + 'sc_trigger': { + 'volume': 0.0, # Sin salida de audio + 'saturator_drive': 0.0, + 'peak_reduction': 0.0, + }, +} + +# Factores de ajuste por estilo +# NOTA: NO usar multiplicadores de volumen que rompan el gain staging +# Solo ajustes sutiles de procesamiento y sends +STYLE_GAIN_ADJUSTMENTS = { + 'industrial': { + 'saturator_drive_factor': 1.3, # Aumentar drive en elementos agresivos + 'additional_heat_send': 0.05, # Un poco mas de heat + 'limiter_gain_factor': 1.15, # +15% gain para industrial techno + }, + 'latin': { + 'additional_pan_width': 0.05, + }, + 'peak-time': { + 'master_compressor_ratio_factor': 1.1, + 'limiter_gain_factor': 1.1, # +10% gain para peak-time + }, + 'minimal': { + 'fx_bus_send_reduction': 0.05, + 'additional_space_send': 0.03, # Un poco mas de reverb para espacio + }, +} + +ROLE_BUS_ASSIGNMENTS = { + 'sc_trigger': 'sc_trigger', # Rutea a su propio bus fantasma + 'kick': 'drums', + 'clap': 'drums', + 'snare_fill': 'drums', + 'hat_closed': 'drums', + 'hat_open': 'drums', + 'top_loop': 'drums', + 'perc': 'drums', + 'tom_fill': 'drums', + 'ride': 'drums', + 'crash': 'drums', + 'sub_bass': 'bass', + 'bass': 'bass', + 'drone': 'music', + 'chords': 'music', + 'stab': 'music', + 'pad': 'music', + 'pluck': 'music', + 'arp': 'music', + 'lead': 'music', + 'counter': 'music', + 'reverse_fx': 'fx', + 'riser': 'fx', + 'impact': 'fx', + 'atmos': 'fx', + 'vocal': 'vocal', +} + +SECTION_BLUEPRINTS = { + 'minimal': [ + ('INTRO', 8, 12, 'intro', 1), + ('GROOVE', 16, 20, 'build', 2), + ('BREAK', 8, 25, 'break', 1), + ('OUTRO', 8, 8, 'outro', 1), + ], + 'standard': [ + ('INTRO', 8, 12, 'intro', 1), + ('BUILD', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAK', 8, 25, 'break', 1), + ('DROP B', 16, 30, 'drop', 5), + ('OUTRO', 8, 8, 'outro', 1), + ], + 'extended': [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('BUILD A', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 25, 'break', 1), + ('BUILD B', 8, 18, 'build', 3), + ('DROP B', 16, 30, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], + 'club': [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('GROOVE A', 16, 14, 'build', 2), + ('VOCAL BUILD', 8, 18, 'build', 3), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 25, 'break', 1), + ('BUILD B', 8, 18, 'build', 3), + ('DROP B', 16, 30, 'drop', 5), + ('PEAK', 8, 32, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], +} + +SECTION_BLUEPRINT_VARIANTS = { + 'standard': [ + SECTION_BLUEPRINTS['standard'], + [ + ('INTRO', 8, 12, 'intro', 1), + ('GROOVE A', 8, 16, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 24, 'break', 1), + ('BUILD B', 8, 20, 'build', 3), + ('DROP B', 16, 31, 'drop', 5), + ], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('BUILD', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('MID BREAK', 8, 22, 'break', 1), + ('PEAK', 16, 31, 'drop', 5), + ], + ], + 'club': [ + SECTION_BLUEPRINTS['club'], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('TEASE', 8, 14, 'build', 2), + ('GROOVE A', 16, 18, 'build', 3), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 24, 'break', 1), + ('BUILD B', 8, 20, 'build', 3), + ('PEAK', 16, 32, 'drop', 5), + ('OUTRO DJ', 24, 8, 'outro', 1), + ], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('GROOVE A', 16, 15, 'build', 2), + ('VOCAL BUILD', 8, 20, 'build', 3), + ('DROP A', 16, 27, 'drop', 4), + ('MID BREAK', 8, 22, 'break', 1), + ('GROOVE B', 8, 18, 'build', 3), + ('DROP B', 24, 31, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], + ], +} + +ROLE_ACTIVITY = { + 'sc_trigger': {'intro': 4, 'build': 4, 'drop': 4, 'break': 2, 'outro': 3}, + 'kick': {'intro': 2, 'build': 3, 'drop': 4, 'break': 1, 'outro': 2}, + 'clap': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'snare_fill': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'hat_closed': {'intro': 1, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'hat_open': {'intro': 0, 'build': 1, 'drop': 3, 'break': 0, 'outro': 1}, + 'top_loop': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'perc': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, + 'tom_fill': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, + 'ride': {'intro': 0, 'build': 1, 'drop': 2, 'break': 0, 'outro': 1}, + 'crash': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, + 'sub_bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'drone': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, + 'chords': {'intro': 0, 'build': 2, 'drop': 3, 'break': 2, 'outro': 1}, + 'stab': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 0}, + 'pad': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, + 'pluck': {'intro': 0, 'build': 2, 'drop': 3, 'break': 0, 'outro': 0}, + 'arp': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, + 'lead': {'intro': 0, 'build': 1, 'drop': 4, 'break': 0, 'outro': 0}, + 'counter': {'intro': 0, 'build': 1, 'drop': 3, 'break': 1, 'outro': 0}, + 'reverse_fx': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'riser': {'intro': 0, 'build': 3, 'drop': 1, 'break': 2, 'outro': 0}, + 'impact': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'atmos': {'intro': 2, 'build': 1, 'drop': 1, 'break': 3, 'outro': 2}, + 'vocal': {'intro': 0, 'build': 1, 'drop': 2, 'break': 1, 'outro': 0}, +} + +# ROLE_MIX: Perfil de mezcla por rol +# Valores base que luego se calibran con ROLE_GAIN_CALIBRATION +# Volumenes calibrados relativos: kick = 0%, otros debajo +# Pan y sends optimizados para profundidad y espacio +ROLE_MIX = { + 'sc_trigger': {'volume': 0.0, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.0}}, + # DRUMS - Kick centered, elements below + 'kick': {'volume': 0.85, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.08}}, + 'clap': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.14, 'echo': 0.04, 'heat': 0.02, 'glue': 0.10}}, + 'snare_fill': {'volume': 0.72, 'pan': 0.0, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, + 'hat_closed': {'volume': 0.68, 'pan': -0.10, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.04}}, + 'hat_open': {'volume': 0.65, 'pan': 0.12, 'sends': {'space': 0.10, 'echo': 0.08, 'heat': 0.01, 'glue': 0.06}}, + 'top_loop': {'volume': 0.62, 'pan': -0.16, 'sends': {'space': 0.06, 'echo': 0.12, 'heat': 0.0, 'glue': 0.08}}, + 'perc': {'volume': 0.70, 'pan': 0.20, 'sends': {'space': 0.10, 'echo': 0.14, 'heat': 0.02, 'glue': 0.10}}, + 'tom_fill': {'volume': 0.68, 'pan': 0.12, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, + 'ride': {'volume': 0.58, 'pan': 0.24, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.06}}, + 'crash': {'volume': 0.50, 'pan': 0.0, 'sends': {'space': 0.18, 'echo': 0.06, 'heat': 0.01, 'glue': 0.02}}, + # BASS - Below drums, centered for mono compatibility + 'sub_bass': {'volume': 0.80, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.14}}, + 'bass': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.01, 'echo': 0.01, 'heat': 0.04, 'glue': 0.12}}, + # MUSIC - Layers below rhythm section + 'drone': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.08, 'heat': 0.02, 'glue': 0.04}}, + 'chords': {'volume': 0.70, 'pan': -0.06, 'sends': {'space': 0.18, 'echo': 0.12, 'heat': 0.01, 'glue': 0.08}}, + 'stab': {'volume': 0.65, 'pan': 0.10, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.04, 'glue': 0.08}}, + 'pad': {'volume': 0.60, 'pan': -0.14, 'sends': {'space': 0.32, 'echo': 0.08, 'heat': 0.0, 'glue': 0.06}}, + 'pluck': {'volume': 0.68, 'pan': 0.14, 'sends': {'space': 0.08, 'echo': 0.18, 'heat': 0.01, 'glue': 0.06}}, + 'arp': {'volume': 0.65, 'pan': -0.18, 'sends': {'space': 0.14, 'echo': 0.24, 'heat': 0.01, 'glue': 0.08}}, + 'lead': {'volume': 0.72, 'pan': 0.06, 'sends': {'space': 0.14, 'echo': 0.18, 'heat': 0.03, 'glue': 0.10}}, + 'counter': {'volume': 0.62, 'pan': 0.20, 'sends': {'space': 0.18, 'echo': 0.14, 'heat': 0.01, 'glue': 0.06}}, + # FX - Deep in the mix + 'reverse_fx': {'volume': 0.52, 'pan': 0.0, 'sends': {'space': 0.24, 'echo': 0.10, 'heat': 0.03, 'glue': 0.02}}, + 'riser': {'volume': 0.60, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.14, 'heat': 0.04, 'glue': 0.03}}, + 'impact': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.22, 'echo': 0.12, 'heat': 0.01, 'glue': 0.03}}, + 'atmos': {'volume': 0.50, 'pan': -0.20, 'sends': {'space': 0.34, 'echo': 0.06, 'heat': 0.0, 'glue': 0.03}}, + # VOCAL - Present but under drums + 'vocal': {'volume': 0.70, 'pan': 0.08, 'sends': {'space': 0.20, 'echo': 0.24, 'heat': 0.02, 'glue': 0.10}}, +} + +ARRANGEMENT_PROFILES = ( + { + 'name': 'warehouse', + 'genres': {'techno', 'tech-house'}, + 'drum_tightness': 1.15, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.12, + 'fx_bias': 1.0, + }, + { + 'name': 'jackin', + 'genres': {'house', 'tech-house'}, + 'drum_tightness': 0.96, + 'bass_motion': 'bouncy', + 'melodic_motion': 'call_response', + 'pan_width': 0.16, + 'fx_bias': 0.92, + }, + { + 'name': 'festival', + 'genres': {'trance', 'house', 'tech-house'}, + 'drum_tightness': 0.92, + 'bass_motion': 'lifted', + 'melodic_motion': 'anthemic', + 'pan_width': 0.2, + 'fx_bias': 1.18, + }, + { + 'name': 'swing', + 'genres': {'tech-house', 'house'}, + 'drum_tightness': 0.9, + 'bass_motion': 'syncopated', + 'melodic_motion': 'hooky', + 'pan_width': 0.22, + 'fx_bias': 1.05, + }, + { + 'name': 'tech-house-club', + 'genres': {'tech-house'}, + 'drum_tightness': 0.94, + 'bass_motion': 'bouncy', + 'melodic_motion': 'hooky', + 'pan_width': 0.18, + 'fx_bias': 1.08, + 'bus_names': { + 'drums': 'DRUM CLUB', + 'bass': 'BASS TUBE', + 'music': 'MUSIC JACK', + 'vocal': 'VOCAL LATIN BUS', + 'fx': 'FX JAM', + }, + 'return_names': { + 'space': 'REVERB SHORT', + 'echo': 'DELAY MONO', + 'heat': 'DRIVE HOT', + 'glue': 'GLUE BUS', + }, + }, + { + 'name': 'tech-house-deep', + 'genres': {'tech-house'}, + 'drum_tightness': 1.02, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.14, + 'fx_bias': 0.88, + 'bus_names': { + 'drums': 'DRUM DEEP', + 'bass': 'SUB DEEP', + 'music': 'ATMOS DEEP', + 'vocal': 'VOX DEEP', + 'fx': 'FX DEEP', + }, + 'return_names': { + 'space': 'REVERB DEEP', + 'echo': 'DELAY DEEP', + 'heat': 'SATURATE DEEP', + 'glue': 'GLUE MINIMAL', + }, + }, + { + 'name': 'tech-house-funky', + 'genres': {'tech-house'}, + 'drum_tightness': 0.86, + 'bass_motion': 'syncopated', + 'melodic_motion': 'hooky', + 'pan_width': 0.24, + 'fx_bias': 1.12, + 'bus_names': { + 'drums': 'DRUM GROOVE', + 'bass': 'BASS FUNK', + 'music': 'MUSIC GROOVE', + 'vocal': 'VOCAL FUNK', + 'fx': 'FX SWING', + }, + 'return_names': { + 'space': 'REVERB GROOVE', + 'echo': 'DELAY GROOVE', + 'heat': 'DRIVE FUNK', + 'glue': 'GLUE SWING', + }, + }, +) + +ROLE_FX_CHAINS = { + 'sc_trigger': [ + {'device': 'Utility', 'parameters': {'Gain': 0.0, 'Width': 0.0}}, + ], + 'kick': [ + {'device': 'Saturator', 'parameters': {'Drive': 2.5}}, + ], + 'clap': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.08}}, + ], + 'snare_fill': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}, + ], + 'hat_closed': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 15000.0, 'Dry/Wet': 0.14}}, + ], + 'hat_open': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.18}}, + ], + 'top_loop': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 11000.0, 'Dry/Wet': 0.22}}, + ], + 'perc': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 9500.0, 'Dry/Wet': 0.16}}, + ], + 'ride': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 12500.0, 'Dry/Wet': 0.12}}, + ], + 'sub_bass': [ + {'device': 'Utility', 'parameters': {'Width': 0.0}}, + ], + 'bass': [ + {'device': 'Saturator', 'parameters': {'Drive': 4.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 7800.0, 'Dry/Wet': 0.12}}, + ], + 'drone': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'chords': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.14}}, + ], + 'stab': [ + {'device': 'Saturator', 'parameters': {'Drive': 3.0}}, + ], + 'pad': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, + ], + 'pluck': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'arp': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'lead': [ + {'device': 'Saturator', 'parameters': {'Drive': 2.0}}, + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'counter': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.1}}, + ], + 'crash': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'reverse_fx': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.24}}, + ], + 'riser': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.28}}, + ], + 'impact': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'atmos': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.3}}, + ], + 'vocal': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.14}}, + ], +} + +SCRIPTS_ROOT = Path(__file__).resolve().parents[2] +REFERENCE_SEARCH_DIRS = ( + SCRIPTS_ROOT / 'sample', + SCRIPTS_ROOT / 'samples', +) +REFERENCE_TRACK_PROFILES = [ + { + 'name': 'Eli Brown x GeezLy - Me Gusta', + 'match_terms': ['eli brown', 'geezly', 'me gusta'], + 'genre': 'tech-house', + 'style': 'latin-industrial', + 'bpm': 136.0, + 'key': 'F#m', + 'structure': 'club', + 'reference_bars': 112, + }, + { + 'name': 'Mr. Pauer, Goyo - Química', + 'match_terms': ['mr. pauer', 'goyo', 'química'], + 'genre': 'house', + 'style': 'latin-funky vocal', + 'bpm': 123.0, + 'key': 'Cm', + 'structure': 'extended', + 'reference_bars': 72, + }, +] + +# ========================================================================= +# SECTION AUTOMATION PARAMETERS +# ========================================================================= + +SECTION_AUTOMATION = { + 'intro': { + 'energy': 0.25, + 'filters': { + 'drums': {'frequency': 8500.0, 'resonance': 0.3, 'dry_wet': 0.12}, + 'bass': {'frequency': 6200.0, 'resonance': 0.25, 'dry_wet': 0.08}, + 'music': {'frequency': 7800.0, 'resonance': 0.2, 'dry_wet': 0.1}, + 'vocal': {'frequency': 9200.0, 'resonance': 0.15, 'dry_wet': 0.06}, + 'fx': {'frequency': 8800.0, 'resonance': 0.18, 'dry_wet': 0.14}, + }, + 'reverb': {'send_level': 0.28, 'decay_time': 2.8, 'size': 0.85}, + 'delay': {'send_level': 0.18, 'feedback': 0.35, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -14.0, 'ratio': 2.0, 'attack': 0.015, 'release': 0.12}, + 'saturation': {'drive': 0.8, 'mix': 0.15}, + 'stereo_width': {'value': 0.92}, + 'envelope_curve': 'ease_in', + }, + 'build': { + 'energy': 0.72, + 'filters': { + 'drums': {'frequency': 4200.0, 'resonance': 0.45, 'dry_wet': 0.22}, + 'bass': {'frequency': 3800.0, 'resonance': 0.35, 'dry_wet': 0.16}, + 'music': {'frequency': 5400.0, 'resonance': 0.28, 'dry_wet': 0.18}, + 'vocal': {'frequency': 6800.0, 'resonance': 0.22, 'dry_wet': 0.12}, + 'fx': {'frequency': 5200.0, 'resonance': 0.32, 'dry_wet': 0.24}, + }, + 'reverb': {'send_level': 0.18, 'decay_time': 2.2, 'size': 0.72}, + 'delay': {'send_level': 0.32, 'feedback': 0.48, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -10.0, 'ratio': 3.5, 'attack': 0.008, 'release': 0.08}, + 'saturation': {'drive': 2.2, 'mix': 0.28}, + 'stereo_width': {'value': 1.08}, + 'envelope_curve': 'ramp_up', + }, + 'drop': { + 'energy': 1.0, + 'filters': { + 'drums': {'frequency': 14500.0, 'resonance': 0.2, 'dry_wet': 0.04}, + 'bass': {'frequency': 9800.0, 'resonance': 0.15, 'dry_wet': 0.03}, + 'music': {'frequency': 12200.0, 'resonance': 0.12, 'dry_wet': 0.05}, + 'vocal': {'frequency': 12800.0, 'resonance': 0.1, 'dry_wet': 0.04}, + 'fx': {'frequency': 11000.0, 'resonance': 0.15, 'dry_wet': 0.08}, + }, + 'reverb': {'send_level': 0.12, 'decay_time': 1.6, 'size': 0.55}, + 'delay': {'send_level': 0.14, 'feedback': 0.28, 'time_l': 0.25, 'time_r': 0.375}, + 'compression': {'threshold': -6.0, 'ratio': 4.5, 'attack': 0.005, 'release': 0.06}, + 'saturation': {'drive': 3.5, 'mix': 0.38}, + 'stereo_width': {'value': 1.18}, + 'envelope_curve': 'punch', + }, + 'break': { + 'energy': 0.38, + 'filters': { + 'drums': {'frequency': 5200.0, 'resonance': 0.55, 'dry_wet': 0.32}, + 'bass': {'frequency': 2800.0, 'resonance': 0.45, 'dry_wet': 0.24}, + 'music': {'frequency': 6400.0, 'resonance': 0.35, 'dry_wet': 0.22}, + 'vocal': {'frequency': 8200.0, 'resonance': 0.28, 'dry_wet': 0.16}, + 'fx': {'frequency': 6800.0, 'resonance': 0.38, 'dry_wet': 0.28}, + }, + 'reverb': {'send_level': 0.42, 'decay_time': 3.5, 'size': 1.0}, + 'delay': {'send_level': 0.38, 'feedback': 0.52, 'time_l': 0.5, 'time_r': 0.75}, + 'compression': {'threshold': -18.0, 'ratio': 1.8, 'attack': 0.025, 'release': 0.18}, + 'saturation': {'drive': 0.5, 'mix': 0.1}, + 'stereo_width': {'value': 1.25}, + 'envelope_curve': 'ease_out', + }, + 'outro': { + 'energy': 0.32, + 'filters': { + 'drums': {'frequency': 6200.0, 'resonance': 0.35, 'dry_wet': 0.18}, + 'bass': {'frequency': 4200.0, 'resonance': 0.28, 'dry_wet': 0.14}, + 'music': {'frequency': 5600.0, 'resonance': 0.25, 'dry_wet': 0.16}, + 'vocal': {'frequency': 7200.0, 'resonance': 0.2, 'dry_wet': 0.1}, + 'fx': {'frequency': 6400.0, 'resonance': 0.28, 'dry_wet': 0.2}, + }, + 'reverb': {'send_level': 0.35, 'decay_time': 3.2, 'size': 0.92}, + 'delay': {'send_level': 0.28, 'feedback': 0.42, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -12.0, 'ratio': 2.2, 'attack': 0.018, 'release': 0.15}, + 'saturation': {'drive': 0.6, 'mix': 0.12}, + 'stereo_width': {'value': 0.98}, + 'envelope_curve': 'ease_out', + }, +} + +# Envelope curve templates for automation interpolation +ENVELOPE_CURVES = { + 'linear': lambda x: x, + 'ease_in': lambda x: x * x, + 'ease_out': lambda x: 1 - (1 - x) ** 2, + 'ease_in_out': lambda x: 3 * x * x - 2 * x * x * x, + 'ramp_up': lambda x: x ** 0.5, + 'ramp_down': lambda x: 1 - (1 - x) ** 2, + 'punch': lambda x: min(1.0, x * 2.0) if x < 0.5 else 1.0 - (1.0 - x) ** 0.5, + 's_curve': lambda x: 1 / (1 + (2.71828 ** (-10 * (x - 0.5)))), + 'exponential': lambda x: (2.71828 ** (x - 1) - 0.3679) / 0.6321, +} + +# ============================================================================= +# AUTOMATIZACION DE DEVICES POR SECCION - FASE 2 +# Parametros especificos por device para cada tipo de seccion +# ============================================================================= + +# Automatizacion de devices en tracks individuales por rol - ENHANCED +SECTION_DEVICE_AUTOMATION = { + # BASS - Filtros, drive y compresion dinamica + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + }, + 'Compressor': { + 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + }, + 'sub_bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, + }, + 'Utility': { + 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, + }, + }, + # PAD - Filtros envolventes con width y reverb + 'pad': { + 'Auto Filter': { + 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, + 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, + 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, + 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, + }, + }, + # ATMOS - Filtros espaciales con movement + 'atmos': { + 'Auto Filter': { + 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, + 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, + 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, + 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, + }, + }, + # FX ELEMENTS + 'reverse_fx': { + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, + }, + }, + 'riser': { + 'Auto Filter': { + 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, + 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, + }, + }, + 'impact': { + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, + 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, + }, + 'Saturator': { + 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, + }, + }, + 'drone': { + 'Auto Filter': { + 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, + 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, + }, + }, + # HATS - Filtros de brillantez con resonance y saturacion + 'hat_closed': { + 'Auto Filter': { + 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, + }, + }, + 'hat_open': { + 'Auto Filter': { + 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, + }, + }, + 'top_loop': { + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, + 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, + }, + }, + # SYNTHS + 'chords': { + 'Auto Filter': { + 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, + }, + }, + 'lead': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, + 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, + }, + }, + 'stab': { + 'Saturator': { + 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, + }, + }, + 'pluck': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, + }, + }, + 'arp': { + 'Echo': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, + 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, + }, + 'Saturator': { + 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, + }, + }, + 'counter': { + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, + }, + }, + # VOCAL + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, + 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, + }, + }, + # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) + 'kick': {}, + 'clap': {}, + 'snare_fill': {}, + 'perc': {}, + 'ride': {}, + 'tom_fill': {}, + 'crash': {}, + 'sc_trigger': {}, +} + +# Automatizacion de devices en BUSES por seccion - ENHANCED +BUS_DEVICE_AUTOMATION = { + 'drums': { + 'Compressor': { + 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, + 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, + }, + 'Limiter': { + 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + }, + 'AutoFilter': { + 'Frequency': {'intro': 8500.0, 'build': 12500.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.22, 'drop': 0.04, 'break': 0.35, 'outro': 0.18}, + 'Resonance': {'intro': 0.20, 'build': 0.12, 'drop': 0.08, 'break': 0.50, 'outro': 0.28}, + }, + }, + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, + }, + 'Compressor': { + 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, + 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 4800.0, 'build': 8500.0, 'drop': 12000.0, 'break': 3200.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.05, 'break': 0.25, 'outro': 0.12}, + 'Resonance': {'intro': 0.18, 'build': 0.12, 'drop': 0.08, 'break': 0.45, 'outro': 0.22}, + }, + }, + 'music': { + 'Compressor': { + 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, + 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, + 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.03, 'break': 0.30, 'outro': 0.15}, + 'Resonance': {'intro': 0.18, 'build': 0.10, 'drop': 0.06, 'break': 0.40, 'outro': 0.22}, + }, + 'Utility': { + 'Stereo Width': {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.25, 'outro': 1.05}, + }, + 'Saturator': { + 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, + }, + }, + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.12, 'drop': 0.05, 'break': 0.18, 'outro': 0.08}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.28, 'break': 0.50, 'outro': 0.30}, + }, + 'Compressor': { + 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, + 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.03, 'break': 0.16, 'outro': 0.08}, + 'Decay Time': {'intro': 2.2, 'build': 3.0, 'drop': 1.6, 'break': 4.0, 'outro': 2.5}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11500.0, 'drop': 14500.0, 'break': 6000.0, 'outro': 7200.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.04, 'break': 0.22, 'outro': 0.10}, + 'Resonance': {'intro': 0.15, 'build': 0.10, 'drop': 0.06, 'break': 0.32, 'outro': 0.18}, + }, + }, + 'fx': { + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 10500.0, 'drop': 14000.0, 'break': 4000.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.06, 'outro': 0.18, 'break': 0.35}, + 'Resonance': {'intro': 0.18, 'build': 0.15, 'drop': 0.10, 'break': 0.42, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.10, 'break': 0.38, 'outro': 0.22}, + 'Decay Time': {'intro': 3.0, 'build': 3.8, 'drop': 2.0, 'break': 5.0, 'outro': 3.5}, + }, + 'Limiter': { + 'Gain': {'intro': -0.3, 'build': 0.0, 'drop': 0.2, 'break': -0.5, 'outro': -0.2}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.5, 'drop': 2.2, 'break': 0.8, 'outro': 0.6}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.14, 'drop': 0.20, 'break': 0.10, 'outro': 0.10}, + }, + }, +} + +# Automatizacion de devices en MASTER por seccion - ENHANCED +MASTER_DEVICE_AUTOMATION = { + 'Utility': {'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, + 'Gain': {'intro': 0.72, 'build': 0.88, 'drop': 1.0, 'break': 0.68, 'outro': 0.70}, + }, + 'Saturator': {'Drive': {'intro': 0.18, 'build': 0.30, 'drop': 0.45, 'break': 0.12, 'outro': 0.15}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.06, 'outro': 0.10}, + }, + 'Compressor': {'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.68, 'break': 0.50, 'outro': 0.52}, + 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -13.5, 'break': -8.0, 'outro': -9.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, + 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.10, 'break': 0.18, 'outro': 0.16}, + }, + 'Limiter': {'Gain': {'intro': 1.05, 'build': 1.12, 'drop': 1.20, 'break': 1.00, 'outro': 1.02}, + 'Ceiling': {'intro': -0.5, 'build': -0.7, 'drop': -0.9, 'break': -0.4, 'outro': -0.45}, + }, + 'Auto Filter': {'Frequency': {'intro': 8500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 5500.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.04, 'build': 0.02, 'drop': 0.01, 'break': 0.06, 'outro': 0.05}, + }, + 'Echo': {'Dry/Wet': {'intro': 0.02, 'build': 0.05, 'drop': 0.03, 'break': 0.07, 'outro': 0.03}, + 'Feedback': {'intro': 0.15, 'build': 0.25, 'drop': 0.18, 'break': 0.30, 'outro': 0.20}, + }, +} + +DEVICE_PARAMETER_SAFETY_CLAMPS = { + 'Drive': {'min': 0.0, 'max': 6.0}, + 'Frequency': {'min': 20.0, 'max': 20000.0}, + 'Dry/Wet': {'min': 0.0, 'max': 1.0}, + 'Feedback': {'min': 0.0, 'max': 0.7}, + 'Stereo Width': {'min': 0.0, 'max': 1.3}, + 'Resonance': {'min': 0.0, 'max': 1.0}, + 'Ratio': {'min': 1.0, 'max': 20.0}, + 'Threshold': {'min': -60.0, 'max': 0.0}, + 'Attack': {'min': 0.0001, 'max': 0.5}, + 'Release': {'min': 0.001, 'max': 2.0}, + 'Gain': {'min': -1.0, 'max': 1.8}, + 'Decay Time': {'min': 0.1, 'max': 10.0}, +} + +MASTER_SAFETY_CLAMPS = { + 'Stereo Width': {'min': 0.0, 'max': 1.25}, + 'Drive': {'min': 0.0, 'max': 1.5}, + 'Ratio': {'min': 0.45, 'max': 0.9}, + 'Gain': {'min': 0.0, 'max': 1.6}, + 'Attack': {'min': 0.0001, 'max': 0.1}, + 'Ceiling': {'min': -3.0, 'max': 0.0}, + 'Threshold': {'min': -20.0, 'max': 0.0}, + 'Release': {'min': 0.001, 'max': 1.0}, +} + +# Expanded configuration de variación por sección +SECTION_VARIATION_CONFIG = { + 'perc': { + 'intro': {'sparse': True, 'intensity': 0.3, 'variant': 'ghost'}, + 'build': {'building': True, 'intensity': 0.8, 'variant': 'layering'}, + 'drop': {'full': True, 'intensity': 1.0, 'variant': 'layered'}, + 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'minimal'}, + 'outro': {'fading': True, 'intensity': 0.3, 'variant': 'strip_down'}, + }, + 'perc_alt': { + 'intro': {'sparse': True, 'intensity': 0.2, 'variant': 'minimal'}, + 'build': {'building': True, 'intensity': 0.6, 'variant': 'tension'}, + 'drop': {'full': True, 'intensity': 0.7, 'variant': 'groove'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'atmos'}, + 'outro': {'fading': True, 'intensity': 0.2, 'variant': 'minimal'}, + }, + 'top_loop': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.8, 'variant': 'energy'}, + 'drop': {'full': True, 'intensity': 1.0, 'variant': 'full'}, + 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'filtered'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'hat_open': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.7, 'variant': 'tease'}, + 'drop': {'full': True, 'intensity': 0.9, 'variant': 'offbeat'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'filtered'}, + 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'fading'}, + }, + 'ride': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.6, 'variant': 'building'}, + 'drop': {'full': True, 'intensity': 0.8, 'variant': 'full'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'sparse'}, + 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'minimal'}, + }, + 'snare_fill': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'tension': True, 'intensity': 0.8, 'variant': 'rolling'}, + 'drop': {'impact': True, 'intensity': 0.6, 'variant': 'fill'}, + 'break': {'sparse': True, 'intensity': 0.5, 'variant': 'tension'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'tom_fill': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'rising': True, 'intensity': 0.7, 'variant': 'rising'}, + 'drop': {'impact': True, 'intensity': 0.5, 'variant': 'fill'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'vocal_shot': { + 'intro': {'sparse': True, 'variant': 'hint'}, + 'build': {'building': True, 'variant': 'anticipate'}, + 'drop': {'full': True, 'variant': 'hook'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'fading': True, 'variant': 'minimal'}, + }, + 'synth_peak': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'anthem'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'atmos': { + 'intro': {'full': True, 'decay': 'long', 'variant': 'atmospheric'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'sparse': True, 'variant': 'minimal'}, + 'break': {'full': True, 'decay': 'long', 'variant': 'ethereal'}, + 'outro': {'fading': True, 'decay': 'long', 'variant': 'fading'}, + }, + 'chords': { + 'intro': {'sparse': True, 'variant': 'foreshadow'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'full'}, + 'break': {'sparse': True, 'variant': 'atmospheric'}, + 'outro': {'fading': True, 'variant': 'echo'}, + }, + 'pad': { + 'intro': {'full': True, 'variant': 'atmospheric'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'sparse': True, 'variant': 'minimal'}, + 'break': {'full': True, 'variant': 'ethereal'}, + 'outro': {'fading': True, 'variant': 'decay'}, + }, + 'lead': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'hook'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'arp': { + 'intro': {'sparse': True, 'variant': 'ghost'}, + 'build': {'building': True, 'variant': 'energy'}, + 'drop': {'full': True, 'variant': 'driving'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'pluck': { + 'intro': {'sparse': True, 'variant': 'hint'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'full': True, 'variant': 'punchy'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'fading': True, 'variant': 'strip_down'}, + }, + 'bass': { + 'intro': {'sparse': True, 'variant': 'subtle'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'groove'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'fading': True, 'variant': 'fading'}, + }, + 'sub_bass': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'hint'}, + 'drop': {'full': True, 'variant': 'deep'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'stab': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'sparse': True, 'variant': 'hint'}, + 'drop': {'full': True, 'variant': 'impact'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, +} + +# ========================================================================= +# PATTERN VARIATION SYSTEM - Anti-repetition tracking +# ========================================================================= + +class PatternVariationManager: + """ + Manages pattern variant selection with cross-generation memory + to prevent repetitive patterns across sections and generations. + """ + + def __init__(self): + self.memory: Dict[str, Dict[str, int]] = { + 'drum': {}, + 'bass': {}, + 'melodic': {}, + } + self.section_signatures: List[str] = [] + self.max_memory_age = 5 # Generations before decay + + def record_usage(self, category: str, variant: str) -> None: + """Record that a pattern variant was used.""" + if category not in self.memory: + self.memory[category] = {} + self.memory[category][variant] = self.memory[category].get(variant, 0) + 1 + logger.debug(f"[PATTERN_MEMORY] Recorded {category}:{variant} (count: {self.memory[category][variant]})") + + def get_penalty(self, category: str, variant: str) -> float: + """Get penalty score for a variant based on recent usage.""" + count = self.memory.get(category, {}).get(variant, 0) + penalty = min(0.4, count * 0.08) # Max 40% penalty + if penalty > 0: + logger.debug(f"[PATTERN_MEMORY] Penalty for {category}:{variant} = {penalty:.2f} (used {count}x)") + return penalty + + def decay_memory(self) -> None: + """Decay memory to allow reuse after generations.""" + for category in self.memory: + for variant in list(self.memory[category].keys()): + self.memory[category][variant] = max(0, self.memory[category][variant] - 1) + if self.memory[category][variant] <= 0: + del self.memory[category][variant] + + def reset(self) -> None: + """Reset all memory.""" + self.memory = {'drum': {}, 'bass': {}, 'melodic': {}} + self.section_signatures = [] + logger.info("[PATTERN_MEMORY] Reset all pattern variant memory") + + def compute_section_signature(self, section: Dict[str, Any]) -> str: + """Compute a signature for section to detect repetition.""" + drum_variants = section.get('drum_role_variants', {}) + signature_parts = [ + f"k:{drum_variants.get('kick', 'default')}", + f"c:{drum_variants.get('clap', 'default')}", + f"h:{drum_variants.get('hat_closed', 'default')}", + f"b:{section.get('bass_bank_variant', 'anchor')}", + f"m:{section.get('melodic_bank_variant', 'motif')}", + f"d:{section.get('density', 1.0):.1f}", + ] + return "|".join(signature_parts) + + def check_repetition(self, sections: List[Dict[str, Any]]) -> List[Tuple[int, str]]: + """Check for repetitive sections and return warnings.""" + warnings = [] + signatures = [] + consecutive_same = 0 + + for i, section in enumerate(sections): + sig = self.compute_section_signature(section) + signatures.append(sig) + + if signatures and len(signatures) > 1 and signatures[-2] == sig: + consecutive_same += 1 + if consecutive_same >= 2: + warning_msg = f"[REPETITION_DETECTED] Sections {i-1}-{i} have identical signature: {sig}" + logger.warning(warning_msg) + warnings.append((i, sig)) + else: + consecutive_same = 0 + + return warnings + +# Global pattern variation manager +_pattern_variation_manager = PatternVariationManager() + +def get_pattern_manager() -> PatternVariationManager: + """Get the global pattern variation manager.""" + return _pattern_variation_manager + +# Legacy compatibility functions +def _get_pattern_variant_penalty(category: str, variant: str) -> float: + """Get penalty for a pattern variant (legacy wrapper).""" + return _pattern_variation_manager.get_penalty(category, variant) + +def _record_pattern_variant_usage(category: str, variant: str) -> None: + """Record pattern variant usage (legacy wrapper).""" + _pattern_variation_manager.record_usage(category, variant) + +def _decay_pattern_variant_memory() -> None: + """Decay pattern variant memory (legacy wrapper).""" + _pattern_variation_manager.decay_memory() + +def reset_pattern_variant_memory() -> None: + """Reset all pattern variant memory (legacy wrapper).""" + _pattern_variation_manager.reset() + + +# ============================================================================= +# DRUM PATTERN BANKS - Expanded Section-Specific Variants (11+ kick, 10+ clap, 8+ hat) +# ============================================================================= + +# Section-specific drum variants mapping - EXPANDED with 11+ kick, 10+ clap, 8+ hat variants +DRUM_SECTION_VARIANTS = { + 'intro': { + # KICK: 11 variants - minimal, ghost notes, filtered, etc. + 'kick': ['sparse', 'minimal', 'foreshadow', 'hint', 'ghost', 'filtered', 'subtle', 'pulse', 'sub_bass', 'tick', 'heartbeat'], + # CLAP: 10 variants + 'clap': ['absent', 'hint', 'ghost', 'filtered', 'reverb_tail', 'minimal', 'subtle', 'single', 'distant', 'echo'], + # HAT: 8+ variants + 'hat_closed': ['sparse', 'ghost', 'whisper', 'filtered', 'minimal', 'reverb_tail', 'subtle', 'tick'], + 'hat_open': ['absent', 'hint', 'filtered', 'minimal', 'ghost', 'reverb_tail', 'tick', 'single'], + 'perc': ['minimal', 'atmos', 'ghost', 'subtle', 'filtered', 'tick', 'reverb_tail', 'sparse'], + 'ride': ['absent', 'hint', 'subtle', 'minimal', 'filtered', 'ghost'], + 'top_loop': ['absent', 'hint', 'filtered', 'minimal', 'subtle', 'ghost'], + 'snare_fill': ['absent', 'hint', 'ghost', 'minimal'], + 'tom_fill': ['absent', 'hint', 'ghost', 'filtered'], + }, + 'build': { + # KICK: 11 variants - building energy + 'kick': ['building', 'pressure', 'rising', 'tension', 'accelerate', 'filter_sweep', 'drive_up', 'tighten', 'fill_preparation', 'intensity', 'impact_build'], + # CLAP: 10 variants + 'clap': ['building', 'anticipate', 'roll_in', 'intensify', 'echo_build', 'filter_sweep', 'layering', 'reverb_up', 'drive_up', 'accelerate'], + 'hat_closed': ['building', 'open_up', 'hyper', 'intensify', 'filter_sweep', 'accelerate', 'reverb_up', 'layering'], + 'hat_open': ['building', 'tease', 'accent', 'filter_sweep', 'intensify', 'fill_preparation', 'open_build'], + 'perc': ['layering', 'tension', 'build_up', 'intensify', 'accelerate', 'filter_sweep', 'reverb_up', 'drive_up'], + 'ride': ['building', 'rising', 'intensify', 'filter_sweep', 'reverb_up', 'accelerate'], + 'top_loop': ['building', 'energy', 'intensify', 'filter_sweep', 'drive_up', 'layering'], + 'snare_fill': ['rolling', 'tension', 'accelerate', 'intensify', 'fill_preparation'], + 'tom_fill': ['rising', 'fill', 'intensify', 'accelerate', 'fill_preparation'], + }, + 'drop': { + # KICK: 11 variants - full energy patterns + 'kick': ['full', 'punch', 'four_on_floor', 'groove', 'impact', 'heavy', 'driving', 'tight', 'big_room', 'club', 'techno_thump'], + # CLAP: 10 variants + 'clap': ['full', 'backbeat', 'syncopated', 'punch', 'big', 'layered', 'room', 'tight', 'crisp', 'slap'], + 'hat_closed': ['full', 'groove', 'offbeat', 'shuffle', 'tight', 'driving', 'punchy', 'crisp'], + 'hat_open': ['full', 'offbeat', 'groove', 'accent', 'big', 'room', 'open_drive', 'shuffle'], + 'perc': ['full', 'layered', 'groove', 'latin', 'tribal', 'driving', 'tight', 'energetic'], + 'ride': ['full', 'groove', 'energy', 'driving', 'tight', 'shimmer'], + 'top_loop': ['full', 'energy', 'layered', 'driving', 'tight', 'groove'], + 'snare_fill': ['drop_hit', 'fill', 'impact', 'big', 'accent'], + 'tom_fill': ['drop_hit', 'fill', 'impact', 'big', 'accent'], + }, + 'break': { + # KICK: 11 variants - stripped down + 'kick': ['sparse', 'absent', 'minimal', 'foreshadow', 'ghost', 'filtered', 'subtle', 'heartbeat', 'pulse', 'distant', 'reverb_only'], + # CLAP: 10 variants + 'clap': ['sparse', 'offbeat', 'ghost', 'filtered', 'reverb_tail', 'minimal', 'subtle', 'distant', 'echo', 'single'], + 'hat_closed': ['open', 'sparse', 'atmos', 'filtered', 'minimal', 'reverb_tail', 'subtle', 'ghost'], + 'hat_open': ['sparse', 'filtered', 'minimal', 'ghost', 'reverb_tail', 'subtle', 'atmos', 'distant'], + 'perc': ['minimal', 'atmos', 'filtered', 'ghost', 'reverb_tail', 'subtle', 'sparse', 'distant'], + 'ride': ['sparse', 'filtered', 'minimal', 'ghost', 'reverb_tail', 'subtle'], + 'top_loop': ['filtered', 'hint', 'minimal', 'ghost', 'reverb_tail', 'subtle'], + 'snare_fill': ['tension', 'ghost', 'minimal', 'filtered', 'echo'], + 'tom_fill': ['tension', 'ghost', 'minimal', 'filtered', 'echo'], + }, + 'outro': { + # KICK: 11 variants - fading out + 'kick': ['fading', 'minimal', 'sparse', 'strip_down', 'reverb_tail', 'heartbeat', 'subtle', 'distant', 'filtered', 'pulse', 'fade'], + # CLAP: 10 variants + 'clap': ['fading', 'sparse', 'last_hit', 'minimal', 'reverb_tail', 'distant', 'echo', 'subtle', 'ghost', 'filtered'], + 'hat_closed': ['fading', 'open', 'minimal', 'reverb_tail', 'subtle', 'sparse', 'ghost', 'filtered'], + 'hat_open': ['fading', 'last_hit', 'minimal', 'reverb_tail', 'subtle', 'ghost', 'distant', 'filtered'], + 'perc': ['fading', 'minimal', 'strip_down', 'reverb_tail', 'subtle', 'sparse', 'ghost', 'filtered'], + 'ride': ['fading', 'minimal', 'reverb_tail', 'subtle', 'ghost', 'filtered'], + 'top_loop': ['fading', 'minimal', 'reverb_tail', 'subtle', 'ghost', 'filtered'], + 'snare_fill': ['end_fill', 'absent', 'minimal', 'reverb_tail', 'ghost'], + 'tom_fill': ['end_fill', 'absent', 'minimal', 'reverb_tail', 'ghost'], + }, +} + +# Expanded drum pattern generators for section variation +DRUM_PATTERN_BANKS = { + 'kick': { + 'four_on_floor': [0.0, 1.0, 2.0, 3.0], + 'sparse': [0.0, 2.0], + 'minimal': [0.0], + 'foreshadow': [0.0, 3.5], + 'hint': [0.0, 2.5], + 'building': [0.0, 1.0, 2.0, 3.0, 3.5], + 'pressure': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'rising': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5, 3.75], + 'tension': [0.0, 0.25, 1.0, 1.5, 2.0, 2.75, 3.0, 3.25, 3.5], + 'full': [0.0, 1.0, 2.0, 3.0], + 'punch': [0.0, 0.25, 1.0, 2.0, 3.0], + 'groove': [0.0, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.75], + 'impact': [0.0, 0.25, 0.5, 1.0, 2.0, 3.0], + 'fading': [0.0, 2.0], + 'strip_down': [0.0], + 'absent': [], + }, + 'clap': { + 'backbeat': [1.0, 3.0], + 'sparse': [1.0], + 'hint': [3.0], + 'building': [1.0, 2.5, 3.0], + 'anticipate': [1.0, 2.0, 2.75, 3.0, 3.5], + 'roll_in': [0.75, 1.0, 1.25, 1.5, 2.75, 3.0, 3.25, 3.5], + 'full': [1.0, 3.0], + 'syncopated': [0.75, 1.0, 2.75, 3.0], + 'offbeat': [1.5, 3.5], + 'punch': [0.75, 1.0, 1.25, 2.75, 3.0, 3.25], + 'ghost': [3.0], + 'last_hit': [1.0], + 'fading': [1.0], + 'absent': [], + }, + 'hat_closed': { + 'offbeat': [0.5, 1.5, 2.5, 3.5], + 'sparse': [0.5, 2.5], + 'ghost': [0.25, 1.25, 2.25, 3.25], + 'whisper': [0.75, 1.75, 2.75, 3.75], + 'building': [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'open_up': [0.5, 0.75, 1.5, 1.75, 2.5, 2.75, 3.5, 3.75], + 'hyper': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'groove': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'shuffle': [0.0, 0.33, 0.66, 1.0, 1.33, 1.66, 2.0, 2.33, 2.66, 3.0, 3.33, 3.66], + 'filtered': [0.5, 1.5, 2.5, 3.5], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'fading': [0.5, 2.5], + 'minimal': [0.5], + }, + 'hat_open': { + 'sparse': [2.0], + 'building': [1.5, 2.5, 3.0], + 'full': [0.0, 2.0], + 'offbeat': [1.5, 3.5], + 'tease': [3.5], + 'fading': [2.0], + 'last_hit': [3.5], + 'hint': [2.0], + 'absent': [], + }, + 'perc': { + 'minimal': [1.5], + 'atmos': [0.75, 2.75], + 'ghost': [0.25, 2.25], + 'layering': [0.5, 1.5, 2.5, 3.5], + 'tension': [0.25, 1.25, 2.25, 3.25], + 'build_up': [0.5, 1.0, 2.0, 3.0, 3.5], + 'full': [0.5, 1.0, 1.5, 2.5, 3.0, 3.5], + 'layered': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'groove': [0.5, 1.0, 2.0, 2.5, 3.5], + 'latin': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'tribal': [0.0, 0.5, 1.25, 1.75, 2.5, 3.0, 3.75], + 'filtered': [0.5, 2.5], + 'fading': [1.5], + 'strip_down': [0.0], + 'hint': [2.0], + }, + 'ride': { + 'sparse': [0.0, 2.0], + 'building': [0.0, 1.0, 2.0, 3.0], + 'rising': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'groove': [0.0, 0.25, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5], + 'filtered': [0.0, 2.0], + 'fading': [0.0], + 'minimal': [0.0], + 'absent': [], + }, + 'top_loop': { + 'minimal': [0.25, 1.25, 2.25, 3.25], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 2.25, 2.5, 3.0, 3.25, 3.5], + 'building': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'full': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'layered': [0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.25, 2.5, 2.75, 3.25, 3.5, 3.75], + 'filtered': [0.5, 1.5, 2.5, 3.5], + 'fading': [0.5, 2.5], + 'hint': [1.5, 3.5], + 'absent': [], + }, + 'snare_fill': { + 'rolling': [2.0, 2.125, 2.25, 2.375, 2.5, 2.625, 2.75, 2.875, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'tension': [3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'drop_hit': [0.0], + 'fill': [3.0, 3.25, 3.5, 3.75], + 'end_fill': [0.0, 0.25, 0.5, 0.75], + 'absent': [], + }, + 'tom_fill': { + 'rising': [3.0, 3.2, 3.4, 3.6, 3.8], + 'fill': [3.0, 3.125, 3.25, 3.375, 3.5], + 'drop_hit': [0.0], + 'tension': [3.5, 3.625, 3.75, 3.875], + 'end_fill': [0.0, 0.2, 0.4, 0.6], + 'absent': [], + }, +} + +# Section-specific bass variants - EXPANDED +BASS_SECTION_VARIANTS = { + 'intro': ['subtle', 'hint', 'foreshadow', 'ghost', 'minimal'], + 'build': ['rising', 'tension', 'anticipate', 'building', 'pressure'], + 'drop': ['full', 'punch', 'groove', 'deep', 'impact', 'energy', 'rolling'], + 'break': ['sparse', 'minimal', 'atmos', 'filtered', 'foreshadow'], + 'outro': ['fading', 'minimal', 'subtle', 'strip_down'], +} + +# Expanded bass pattern templates (relative positions in 4-bar cycle) +BASS_PATTERN_BANKS = { + 'anchor': { + 'positions': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.5, 0.5, 0.5], + 'style': 'root_heavy' + }, + 'subtle': { + 'positions': [0.0, 2.0], + 'durations': [0.3, 0.3], + 'style': 'minimal' + }, + 'hint': { + 'positions': [0.0, 3.5], + 'durations': [0.25, 0.25], + 'style': 'foreshadow' + }, + 'foreshadow': { + 'positions': [0.0, 1.0, 3.0, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3], + 'style': 'building' + }, + 'ghost': { + 'positions': [0.5, 2.5], + 'durations': [0.2, 0.2], + 'style': 'minimal' + }, + 'rising': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5, 0.4], + 'style': 'ascending' + }, + 'tension': { + 'positions': [0.0, 0.75, 1.5, 2.25, 3.0, 3.5], + 'durations': [0.5, 0.25, 0.5, 0.25, 0.5, 0.3], + 'style': 'syncopated' + }, + 'anticipate': { + 'positions': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5], + 'durations': [0.5, 0.5, 0.4, 0.2, 0.4, 0.2, 0.4], + 'style': 'building' + }, + 'building': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.3, 0.2, 0.3, 0.2], + 'style': 'ascending' + }, + 'pressure': { + 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.3, 0.2, 0.3, 0.2, 0.4, 0.4, 0.4, 0.4, 0.3, 0.2, 0.3, 0.2], + 'style': 'intense' + }, + 'full': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.5, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], + 'style': 'groove' + }, + 'punch': { + 'positions': [0.0, 0.25, 1.0, 2.0, 3.0], + 'durations': [0.6, 0.2, 0.5, 0.5, 0.5], + 'style': 'punchy' + }, + 'groove': { + 'positions': [0.0, 0.25, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.5], + 'durations': [0.4, 0.2, 0.3, 0.4, 0.3, 0.4, 0.3, 0.4, 0.3], + 'style': 'syncopated' + }, + 'deep': { + 'positions': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.8, 0.8, 0.8, 0.8], + 'style': 'sub' + }, + 'impact': { + 'positions': [0.0, 0.5, 1.5, 2.0, 3.0, 3.5], + 'durations': [0.6, 0.4, 0.3, 0.5, 0.5, 0.4], + 'style': 'punchy' + }, + 'energy': { + 'positions': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.25, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], + 'style': 'driving' + }, + 'rolling': { + 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15], + 'style': 'rolling' + }, + 'sparse': { + 'positions': [0.0, 2.0], + 'durations': [0.4, 0.4], + 'style': 'minimal' + }, + 'minimal': { + 'positions': [0.0], + 'durations': [0.3], + 'style': 'hint' + }, + 'atmos': { + 'positions': [0.0, 3.0], + 'durations': [0.6, 0.4], + 'style': 'atmospheric' + }, + 'filtered': { + 'positions': [0.0, 1.5, 2.5], + 'durations': [0.4, 0.3, 0.3], + 'style': 'filtered' + }, + 'fading': { + 'positions': [0.0, 2.0], + 'durations': [0.5, 0.3], + 'style': 'decay' + }, + 'strip_down': { + 'positions': [0.0], + 'durations': [0.25], + 'style': 'minimal' + }, + 'bounce': { + 'positions': [0.0, 0.5, 1.5, 2.0, 2.5, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.4, 0.3, 0.4], + 'style': 'bouncy' + }, + 'syncopated': { + 'positions': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2], + 'style': 'offbeat' + }, +} + +# Pattern variant diversity memory - track used variants across generations +_pattern_variant_memory: Dict[str, Dict[str, int]] = { + 'drum': {}, + 'bass': {}, + 'melodic': {}, +} + +def _get_pattern_variant_penalty(category: str, variant: str) -> float: + """Get penalty for a pattern variant based on cross-generation usage.""" + if variant in _pattern_variant_memory.get(category, {}): + count = _pattern_variant_memory[category].get(variant, 0) + return min(0.4, count * 0.08) + return 0.0 + +def _record_pattern_variant_usage(category: str, variant: str) -> None: + """Record that a pattern variant was used.""" + if category not in _pattern_variant_memory: + _pattern_variant_memory[category] = {} + _pattern_variant_memory[category][variant] = _pattern_variant_memory[category].get(variant, 0) + 1 + +def _decay_pattern_variant_memory() -> None: + """Decay pattern variant memory to allow reuse after generations.""" + for category in _pattern_variant_memory: + for variant in list(_pattern_variant_memory[category].keys()): + _pattern_variant_memory[category][variant] = max(0, _pattern_variant_memory[category][variant] - 1) + if _pattern_variant_memory[category][variant] <= 0: + del _pattern_variant_memory[category][variant] + +def reset_pattern_variant_memory() -> None: + """Reset all pattern variant memory.""" + global _pattern_variant_memory + _pattern_variant_memory = {'drum': {}, 'bass': {}, 'melodic': {}} + +# Expanded fill patterns for section transitions +FILL_PATTERNS = { + 'drum_fill_4bar': { + 'roles': ['snare', 'kick', 'hat'], + 'pattern': { + 'snare': [3.0, 3.25, 3.5, 3.75], + 'kick': [3.5], + 'hat': [3.0, 3.5] + }, + 'velocities': {'snare': 100, 'kick': 90, 'hat': 70} + }, + 'drum_fill_2bar': { + 'roles': ['snare', 'hat'], + 'pattern': { + 'snare': [1.5, 1.75], + 'hat': [1.5] + }, + 'velocities': {'snare': 95, 'hat': 65} + }, + 'snare_roll': { + 'roles': ['snare'], + 'pattern': { + 'snare': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] + }, + 'velocities': {'snare': 85} + }, + 'hat_open_build': { + 'roles': ['hat_open'], + 'pattern': { + 'hat_open': [0.0, 0.5, 1.0, 1.5, 2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875] + }, + 'velocities': {'hat_open': 75} + }, + 'kick_drop': { + 'roles': ['kick'], + 'pattern': { + 'kick': [0.0] + }, + 'velocities': {'kick': 127} + }, + 'crash_impact': { + 'roles': ['crash'], + 'pattern': { + 'crash': [0.0] + }, + 'velocities': {'crash': 100} + }, + 'snare_roll_build': { + 'roles': ['snare', 'hat'], + 'pattern': { + 'snare': [2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'hat': [2.0, 2.5, 3.0, 3.5] + }, + 'velocities': {'snare': 88, 'hat': 70} + }, + 'tom_build': { + 'roles': ['tom_fill'], + 'pattern': { + 'tom_fill': [2.0, 2.2, 2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8] + }, + 'velocities': {'tom_fill': 90} + }, + 'full_impact': { + 'roles': ['kick', 'snare', 'crash'], + 'pattern': { + 'kick': [0.0], + 'snare': [0.0, 0.25], + 'crash': [0.0] + }, + 'velocities': {'kick': 127, 'snare': 110, 'crash': 105} + }, + 'hat_tension': { + 'roles': ['hat_closed'], + 'pattern': { + 'hat_closed': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] + }, + 'velocities': {'hat_closed': 72} + }, + 'percussion_fill': { + 'roles': ['perc'], + 'pattern': { + 'perc': [0.5, 0.75, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5] + }, + 'velocities': {'perc': 78} + }, + 'minimal_drop': { + 'roles': ['kick'], + 'pattern': { + 'kick': [0.0] + }, + 'velocities': {'kick': 120} + }, + 'build_tension': { + 'roles': ['snare', 'hat_closed', 'kick'], + 'pattern': { + 'snare': [2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'hat_closed': [2.0, 2.5, 3.0, 3.5], + 'kick': [0.0] + }, + 'velocities': {'snare': 92, 'hat_closed': 68, 'kick': 95} + }, + 'outro_fade': { + 'roles': ['hat_closed', 'perc'], + 'pattern': { + 'hat_closed': [0.0, 0.5, 1.0], + 'perc': [0.25, 0.75, 1.25] + }, + 'velocities': {'hat_closed': 80, 'perc': 70} + }, +} + +# Expanded transition events between sections +TRANSITION_EVENTS = { + ('intro', 'build'): ['hat_tension', 'hat_open_build'], + ('build', 'drop'): ['full_impact', 'crash_impact', 'kick_drop', 'snare_roll_build'], + ('drop', 'break'): ['drum_fill_4bar', 'percussion_fill'], + ('break', 'build'): ['hat_tension', 'hat_open_build'], + ('break', 'drop'): ['crash_impact', 'kick_drop', 'full_impact'], + ('drop', 'outro'): ['drum_fill_2bar', 'outro_fade'], + ('outro', 'end'): ['minimal_drop'], +} + +# Rules for preventing transition overcrowding +TRANSITION_DENSITY_RULES = { + # Max fills per section kind + 'max_fills_by_section': { + 'intro': 1, # Minimal fills in intro + 'build': 3, # More fills for tension + 'drop': 2, # Moderate fills + 'break': 2, # Sparse + 'outro': 1, # Minimal + }, + + # Events that should not stack together + 'exclusive_events': [ + {'crash_impact', 'kick_drop'}, # Don't stack impact events + {'drum_fill_4bar', 'snare_roll'}, # Choose one drum fill + ], + + # Minimum distance between same-type fills (in beats) + 'min_distance_same_type': { + 'crash_impact': 8.0, + 'kick_drop': 16.0, + 'snare_roll': 4.0, + } +} + +# Section-specific melodic variants - EXPANDED +MELODIC_SECTION_VARIANTS = { + 'intro': ['subtle', 'foreshadow', 'atmospheric', 'ghost', 'hint'], + 'build': ['rising', 'tension', 'anticipate', 'building', 'energy'], + 'drop': ['hook', 'anthem', 'full', 'punchy', 'impact', 'driving'], + 'break': ['sparse', 'minimal', 'ethereal', 'filtered', 'atmospheric'], + 'outro': ['fading', 'echo', 'minimal', 'strip_down', 'decay'], +} + +# Expanded melodic pattern templates +MELODIC_PATTERN_BANKS = { + 'motif': { + 'intervals': [0, 4, 7, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5], + 'durations': [0.4, 0.3, 0.4, 0.3], + 'style': 'repeating' + }, + 'subtle': { + 'intervals': [0, 0], + 'rhythm': [0.0, 2.0], + 'durations': [0.3, 0.3], + 'style': 'minimal' + }, + 'foreshadow': { + 'intervals': [0, 4, 0], + 'rhythm': [0.0, 1.0, 3.5], + 'durations': [0.4, 0.3, 0.5], + 'style': 'hint' + }, + 'atmospheric': { + 'intervals': [0, 2, 4, 5, 7], + 'rhythm': [0.0, 0.8, 1.6, 2.4, 3.2], + 'durations': [0.8, 0.7, 0.6, 0.5, 0.4], + 'style': 'pad' + }, + 'ghost': { + 'intervals': [0, 7], + 'rhythm': [0.5, 2.5], + 'durations': [0.2, 0.2], + 'style': 'minimal' + }, + 'hint': { + 'intervals': [0, 5], + 'rhythm': [0.0, 3.0], + 'durations': [0.25, 0.25], + 'style': 'minimal' + }, + 'rising': { + 'intervals': [0, 2, 4, 5, 7, 9, 11, 12], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.35, 0.4, 0.35, 0.4, 0.35, 0.5, 0.4], + 'style': 'ascending' + }, + 'tension': { + 'intervals': [0, 1, 0, 1, 2, 1, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'durations': [0.3, 0.2, 0.3, 0.2, 0.3, 0.2, 0.5], + 'style': 'chromatic' + }, + 'anticipate': { + 'intervals': [0, 4, 7, 9, 12], + 'rhythm': [0.0, 1.0, 2.0, 3.0, 3.75], + 'durations': [0.5, 0.4, 0.5, 0.3, 0.5], + 'style': 'buildup' + }, + 'building': { + 'intervals': [0, 2, 4, 5, 7, 9, 11], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.75, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], + 'style': 'ascending' + }, + 'energy': { + 'intervals': [0, 4, 7, 9, 12, 14], + 'rhythm': [0.0, 0.25, 0.75, 1.25, 2.0, 2.75], + 'durations': [0.3, 0.25, 0.3, 0.25, 0.4, 0.5], + 'style': 'driving' + }, + 'hook': { + 'intervals': [0, 4, 7, 4, 0, 4, 7, 12], + 'rhythm': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75], + 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.3], + 'style': 'catchy' + }, + 'anthem': { + 'intervals': [0, 4, 7, 12, 11, 7, 4, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.4, 0.4, 0.5, 0.4, 0.4, 0.4, 0.5], + 'style': 'big' + }, + 'full': { + 'intervals': [0, 4, 7, 5, 4, 2, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], + 'style': 'melodic' + }, + 'punchy': { + 'intervals': [0, 7, 0, 12], + 'rhythm': [0.0, 0.25, 0.5, 0.75], + 'durations': [0.15, 0.15, 0.15, 0.2], + 'style': 'staccato' + }, + 'impact': { + 'intervals': [0, 5, 7, 12, 7, 5], + 'rhythm': [0.0, 0.5, 0.75, 1.5, 2.25, 3.0], + 'durations': [0.4, 0.25, 0.3, 0.5, 0.3, 0.4], + 'style': 'driving' + }, + 'driving': { + 'intervals': [0, 4, 7, 4, 0, 4, 5, 7], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.4], + 'style': 'repeating' + }, + 'sparse': { + 'intervals': [0, 7], + 'rhythm': [0.0, 2.0], + 'durations': [0.4, 0.4], + 'style': 'minimal' + }, + 'minimal': { + 'intervals': [0], + 'rhythm': [0.0], + 'durations': [0.3], + 'style': 'single' + }, + 'ethereal': { + 'intervals': [0, 7, 12, 7], + 'rhythm': [0.0, 1.5, 2.5, 3.5], + 'durations': [1.0, 0.8, 1.0, 0.8], + 'style': 'pad' + }, + 'filtered': { + 'intervals': [0, 4, 7, 5], + 'rhythm': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.4, 0.5, 0.4], + 'style': 'filtered' + }, + 'fading': { + 'intervals': [0, 4, 0], + 'rhythm': [0.0, 1.0, 2.0], + 'durations': [0.5, 0.4, 0.3], + 'style': 'decay' + }, + 'echo': { + 'intervals': [0, 0, 0], + 'rhythm': [0.0, 0.5, 1.0], + 'durations': [0.3, 0.25, 0.2], + 'style': 'repeat' + }, + 'response': { + 'intervals': [7, 4, 0], + 'rhythm': [0.5, 1.5, 2.5], + 'durations': [0.3, 0.3, 0.4], + 'style': 'call_response' + }, + 'lift': { + 'intervals': [0, 4, 7, 12, 14, 16], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5], + 'durations': [0.3, 0.3, 0.3, 0.4, 0.3, 0.4], + 'style': 'ascending' + }, + 'strip_down': { + 'intervals': [0], + 'rhythm': [0.0], + 'durations': [0.25], + 'style': 'minimal' + }, + 'decay': { + 'intervals': [0, 7, 5, 3], + 'rhythm': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.4, 0.3, 0.2], + 'style': 'descending' + }, + 'call_response': { + 'intervals': [0, 4, 7, 0, 7, 4], + 'rhythm': [0.0, 0.25, 0.5, 1.5, 2.0, 2.5], + 'durations': [0.25, 0.2, 0.3, 0.35, 0.25, 0.3], + 'style': 'call_response' + }, +} + +# ============================================================================= +# MASTER CHAIN AUTOMATION TARGETS +# ============================================================================= + + +@dataclass +class StyleConfig: + """Configuración de estilo musical""" + genre: str + bpm: float + key: str + scale: str + density: str # minimal, normal, busy + complexity: str # simple, moderate, complex + + +class SongGenerator: + """Generador de configuraciones y patrones musicales""" + + def __init__(self): + self.logger = logging.getLogger("SongGenerator") + self._current_generation_profile = { + 'name': 'default', + 'seed': 0, + 'drum_tightness': 1.0, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.12, + 'fx_bias': 1.0, + } + # Track style adjustments and calibrated volumes for this generation + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + # Tracking for ROLE_GAIN_CALIBRATION overrides + self._gain_calibration_overrides_count = 0 + self._peak_reductions_count = 0 + self._master_profile_used = 'default' + + # ========================================================================= + # UTILIDADES MUSICALES + # ========================================================================= + + def note_name_to_midi(self, note_name: str, octave: int = 3) -> int: + """Convierte nombre de nota a número MIDI""" + note_name = note_name.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + note_name = note_name.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + try: + note_idx = NOTE_NAMES.index(note_name.upper()) + return (octave + 1) * 12 + note_idx + except ValueError: + return 60 # Default C4 + + def midi_to_note_name(self, midi_note: int) -> tuple: + """Convierte MIDI a (nota, octava)""" + octave = (midi_note // 12) - 1 + note_name = NOTE_NAMES[midi_note % 12] + return note_name, octave + + def get_scale_notes(self, root_note: Union[int, str], scale_name: str = 'minor') -> List[int]: + """Obtiene las notas de una escala""" + if isinstance(root_note, str): + root_midi = self.note_name_to_midi(root_note) + else: + root_midi = root_note + + scale_intervals = SCALES.get(scale_name, SCALES['minor']) + return [root_midi + interval for interval in scale_intervals] + + def quantize_to_scale(self, note: int, scale_notes: List[int]) -> int: + """Cuantiza una nota a la escala más cercana""" + if note in scale_notes: + return note + return min(scale_notes, key=lambda x: abs(x - note)) + + # ========================================================================= + # GENERACIÓN DE CONFIGURACIONES + # ========================================================================= + + def _make_note(self, pitch: int, start: float, duration: float, velocity: int) -> Dict[str, Any]: + return { + 'pitch': max(0, min(127, int(pitch))), + 'start': round(float(start), 3), + 'duration': round(max(0.05, float(duration)), 3), + 'velocity': max(1, min(127, int(velocity))), + } + + def _repeat_pattern(self, pattern: List[Dict[str, Any]], total_length: float, pattern_length: float = 4.0) -> List[Dict[str, Any]]: + if not pattern or total_length <= 0 or pattern_length <= 0: + return [] + + notes = [] + repeats = max(1, int(round(total_length / pattern_length))) + for repeat_index in range(repeats): + offset = repeat_index * pattern_length + for note in pattern: + start = float(note['start']) + offset + if start >= total_length: + continue + duration = min(float(note['duration']), total_length - start) + notes.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + return notes + + def _section_rng(self, section: Dict[str, Any], role: str, salt: int = 0) -> random.Random: + base_seed = int(self._current_generation_profile.get('seed', 0)) + section_index = int(section.get('index', 0)) + role_fingerprint = sum((index + 1) * ord(char) for index, char in enumerate(str(role))) + return random.Random(base_seed + (section_index * 1009) + (role_fingerprint * 17) + (salt * 7919)) + + def _clamp_pan(self, value: float) -> float: + return round(max(-1.0, min(1.0, float(value))), 3) + + def _clamp_unit(self, value: float) -> float: + return round(max(0.0, min(1.0, float(value))), 3) + + def _apply_swing(self, notes: List[Dict[str, Any]], amount: float, section_length: float) -> List[Dict[str, Any]]: + if not notes or abs(amount) < 0.001: + return notes + + swung = [] + for note in notes: + start = float(note['start']) + fractional = round(start % 1.0, 3) + if 0.001 < fractional < 0.999: + shift = amount if fractional >= 0.5 else (amount * -0.45) + start = min(max(0.0, start + shift), max(0.0, section_length - 0.05)) + swung.append(self._make_note(note['pitch'], start, note['duration'], note['velocity'])) + swung.sort(key=lambda item: (item['start'], item['pitch'])) + return swung + + def _apply_density_mask(self, notes: List[Dict[str, Any]], section: Dict[str, Any], role: str, + keep_probability: float) -> List[Dict[str, Any]]: + if not notes or keep_probability >= 0.995: + return notes + + rng = self._section_rng(section, role, salt=3) + filtered = [] + for note in notes: + start = float(note['start']) + if abs(start % 1.0) < 0.001: + filtered.append(note) + continue + if rng.random() <= keep_probability: + filtered.append(note) + return filtered or notes[:1] + + def _build_arrangement_profile(self, genre: str, style: str, variant_seed: int) -> Dict[str, Any]: + style_text = "{} {}".format(genre, style).lower() + candidates = [profile for profile in ARRANGEMENT_PROFILES if genre in set(profile.get('genres', ()))] + + if 'latin' in style_text: + candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['swing', 'jackin']] or candidates + elif 'industrial' in style_text: + candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['warehouse', 'festival']] or candidates + + if not candidates: + candidates = list(ARRANGEMENT_PROFILES) + + rng = random.Random(int(variant_seed) + 41) + selected = dict(rng.choice(candidates)) + selected['seed'] = int(variant_seed) + return selected + + def _extend_parallel_sends(self, role: str, sends: Dict[str, Any]) -> Dict[str, Any]: + resolved = dict(sends or {}) + if role in ['kick', 'clap', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'snare_fill', 'tom_fill']: + resolved.setdefault('glue', 0.1) + resolved.setdefault('heat', 0.05) + elif role in ['sub_bass', 'bass', 'stab']: + resolved.setdefault('glue', 0.08) + resolved.setdefault('heat', 0.08) + elif role in ['chords', 'pad', 'pluck', 'arp', 'lead', 'counter', 'vocal']: + resolved.setdefault('glue', 0.04) + elif role in ['reverse_fx', 'riser', 'impact', 'atmos', 'drone', 'crash']: + resolved.setdefault('glue', 0.03) + return resolved + + def _resolve_bus_for_role(self, role: str) -> Optional[str]: + return ROLE_BUS_ASSIGNMENTS.get(str(role or '').strip().lower(), 'music') + + def _get_section_variation(self, role: str, section_kind: str) -> Dict[str, Any]: + """ + Obtiene configuración de variación para un rol y sección. + + Retorna dict con: + - use: bool - si el rol debe usarse en esta sección + - sparse: bool - si usar variante sparse + - full: bool - si usar variante completa + - intensity: float - intensidad de 0 a 1 + - etc. + """ + if role not in SECTION_VARIATION_CONFIG: + return {'use': True, 'intensity': 1.0} + + role_config = SECTION_VARIATION_CONFIG[role] + return role_config.get(section_kind.lower(), {'use': True, 'intensity': 1.0}) + + def _should_vary_role_in_section(self, role: str, section_kind: str) -> bool: + """Determina si un rol debe variar en una sección dada.""" + if role not in SECTION_VARIATION_CONFIG: + return False + + config = self._get_section_variation(role, section_kind) + + # Si tiene clave 'use' explícita + if 'use' in config: + return config['use'] + + # Si tiene variantes específicas + return any(k in config for k in ['sparse', 'full', 'building', 'fading']) + + def _build_mix_bus_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + + buses = [ + { + 'key': 'drums', + 'name': 'DRUM BUS', + 'color': BUS_TRACK_COLORS['drums'], + 'volume': 0.86, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -16.5}}, + {'device': 'Saturator', 'parameters': {'Drive': 1.2}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.3}}, + ], + }, + { + 'key': 'bass', + 'name': 'BASS BUS', + 'color': BUS_TRACK_COLORS['bass'], + 'volume': 0.8, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Saturator', 'parameters': {'Drive': 1.3}}, + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 0.0}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'music', + 'name': 'MUSIC BUS', + 'color': BUS_TRACK_COLORS['music'], + 'volume': 0.8, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -21.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 12800.0, 'Dry/Wet': 0.05}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 1.12}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'vocal', + 'name': 'VOCAL BUS', + 'color': BUS_TRACK_COLORS['vocal'], + 'volume': 0.82, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.05}}, + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.05}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'fx', + 'name': 'FX BUS', + 'color': BUS_TRACK_COLORS['fx'], + 'volume': 0.76, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 10200.0, 'Dry/Wet': 0.1}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, + {'device': 'Utility', 'parameters': {'Gain': -0.2}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, + ], + }, + ] + + # ========================================================================= + # Apply BUS_GAIN_CALIBRATION as safe baseline BEFORE profile overrides + # ========================================================================= + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + + def find_device_in_chain(fx_chain, device_type): + for device in fx_chain: + if device.get('device') == device_type: + return device + return None + + for bus in buses: + bus_key = bus.get('key', '') + if bus_key not in BUS_GAIN_CALIBRATION: + continue + + calibration = BUS_GAIN_CALIBRATION[bus_key] + + if 'volume' in calibration: + bus['volume'] = calibration['volume'] + + fx_chain = bus.get('fx_chain', []) + + if 'compressor_threshold' in calibration: + compressor = find_device_in_chain(fx_chain, 'Compressor') + if compressor: + compressor['parameters']['Threshold'] = calibration['compressor_threshold'] + + if 'saturator_drive' in calibration: + saturator = find_device_in_chain(fx_chain, 'Saturator') + if saturator: + saturator['parameters']['Drive'] = calibration['saturator_drive'] + + if 'limiter_gain' in calibration: + limiter = find_device_in_chain(fx_chain, 'Limiter') + if limiter: + limiter['parameters']['Gain'] = calibration['limiter_gain'] + + if 'utility_gain' in calibration: + for device in fx_chain: + if device.get('device') == 'Utility': + if 'Gain' in device.get('parameters', {}): + device['parameters']['Gain'] = calibration['utility_gain'] + break + elif 'Stereo Width' not in device.get('parameters', {}): + device['parameters']['Gain'] = calibration['utility_gain'] + break + + # ========================================================================= + # Profile-specific overrides ON TOP of calibrated baselines + # ========================================================================= + if profile_name == 'warehouse': + buses[0]['name'] = 'DRUM BUNKER' + buses[0]['fx_chain'][1]['parameters']['Drive'] = 3.1 + buses[1]['name'] = 'LOW END BUS' + buses[1]['fx_chain'][0]['parameters']['Drive'] = 4.0 + buses[2]['fx_chain'][1]['parameters']['Frequency'] = 11200.0 + elif profile_name == 'festival': + buses[2]['name'] = 'MUSIC WIDE' + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.14 + buses[3]['name'] = 'VOCAL TAIL' + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[4]['name'] = 'FX WASH' + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 + elif profile_name == 'swing': + buses[0]['name'] = 'DRUM POCKET' + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -13.5 + buses[3]['name'] = 'VOCAL SLAP' + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 + elif profile_name == 'jackin': + buses[0]['name'] = 'DRUM CLUB' + buses[2]['name'] = 'MUSIC JACK' + buses[3]['name'] = 'VOX CLUB' + buses[4]['name'] = 'FX JAM' + elif profile_name == 'tech-house-club': + # Club-oriented tech-house with punchy drums and latin vocal treatment + buses[0]['name'] = 'DRUM CLUB' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -15.5 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 2.2 + buses[1]['name'] = 'BASS TUBE' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.5 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -17.0 + buses[2]['name'] = 'MUSIC JACK' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.16 + buses[3]['name'] = 'VOCAL LATIN BUS' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.10 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[4]['name'] = 'FX JAM' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 + elif profile_name == 'tech-house-deep': + # Minimal deep tech-house with subtle processing + buses[0]['name'] = 'DRUM DEEP' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -18.0 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 0.8 + buses[1]['name'] = 'SUB DEEP' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 1.0 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -20.0 + buses[2]['name'] = 'ATMOS DEEP' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][0]['parameters']['Threshold'] = -24.0 + buses[2]['fx_chain'][1]['parameters']['Frequency'] = 10200.0 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.08 + buses[3]['name'] = 'VOX DEEP' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.04 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.06 + buses[4]['name'] = 'FX DEEP' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.08 + elif profile_name == 'tech-house-funky': + # Groovy tech-house with wide stereo and bouncy feel + buses[0]['name'] = 'DRUM GROOVE' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.5 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 1.8 + buses[1]['name'] = 'BASS FUNK' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.0 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -16.5 + buses[2]['name'] = 'MUSIC GROOVE' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][0]['parameters']['Threshold'] = -20.0 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.20 + buses[3]['name'] = 'VOCAL FUNK' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.10 + buses[4]['name'] = 'FX SWING' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.16 + + if 'industrial' in style_text: + buses[0]['fx_chain'][1]['parameters']['Drive'] = max( + 3.4, + float(buses[0]['fx_chain'][1]['parameters'].get('Drive', 2.2)), + ) + buses[1]['fx_chain'][0]['parameters']['Drive'] = max( + 4.2, + float(buses[1]['fx_chain'][0]['parameters'].get('Drive', 3.2)), + ) + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + buses[3]['name'] = 'VOCAL LATIN BUS' + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.14 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.0 + + # ========================================================================= + # Apply STYLE_GAIN_ADJUSTMENTS as multipliers AFTER profile overrides + # ========================================================================= + for style_key, adjustments in STYLE_GAIN_ADJUSTMENTS.items(): + if style_key.lower() in style_text: + self._style_adjustments_applied.append(style_key) + + # Apply bus volume factors + if 'drums_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'drums': + bus['volume'] = bus.get('volume', 0.8) * adjustments['drums_bus_volume_factor'] + + if 'bass_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'bass': + bus['volume'] = bus.get('volume', 0.8) * adjustments['bass_bus_volume_factor'] + + if 'vocal_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'vocal': + bus['volume'] = bus.get('volume', 0.8) * adjustments['vocal_bus_volume_factor'] + + if 'music_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'music': + bus['volume'] = bus.get('volume', 0.8) * adjustments['music_bus_volume_factor'] + + if 'fx_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'fx': + bus['volume'] = bus.get('volume', 0.8) * adjustments['fx_bus_volume_factor'] + + # Apply saturator_drive_factor to all bus saturators + if 'saturator_drive_factor' in adjustments: + for bus in buses: + fx_chain = bus.get('fx_chain', []) + saturator = find_device_in_chain(fx_chain, 'Saturator') + if saturator and 'Drive' in saturator.get('parameters', {}): + saturator['parameters']['Drive'] = ( + saturator['parameters']['Drive'] * adjustments['saturator_drive_factor'] + ) + + # Apply limiter_gain_factor to all bus limiters + if 'limiter_gain_factor' in adjustments: + for bus in buses: + fx_chain = bus.get('fx_chain', []) + limiter = find_device_in_chain(fx_chain, 'Limiter') + if limiter and 'Gain' in limiter.get('parameters', {}): + limiter['parameters']['Gain'] = ( + limiter['parameters']['Gain'] * adjustments['limiter_gain_factor'] + ) + + # Store final calibrated bus volumes + for bus in buses: + bus_key = bus.get('key', '') + if bus_key: + self._calibrated_bus_volumes[bus_key] = bus.get('volume', 0.0) + + # RCA Fix: Automatic Makeup and Output gain compensation + for bus in buses: + for device in bus.get('fx_chain', []): + device_type = device.get('device') + params = device.get('parameters', {}) + if device_type == 'Compressor' and 'Threshold' in params: + params['Makeup'] = round(abs(params['Threshold']) * 0.25, 1) + elif device_type == 'Saturator' and 'Drive' in params: + params['Output'] = round(-params['Drive'] * 1.5, 1) + + return buses + + def _build_return_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + returns = [ + { + 'name': 'MCP SPACE', + 'send_key': 'space', + 'color': 56, + 'device_chain': [{'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}], + 'volume': 0.76, + }, + { + 'name': 'MCP ECHO', + 'send_key': 'echo', + 'color': 44, + 'device_chain': [{'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}], + 'volume': 0.72, + }, + { + 'name': 'MCP HEAT', + 'send_key': 'heat', + 'color': 12, + 'device_chain': [ + {'device': 'Saturator', 'parameters': {'Drive': 4.5}}, + {'device': 'Compressor', 'parameters': {'Threshold': -16.0}}, + ], + 'volume': 0.62, + }, + { + 'name': 'MCP GLUE', + 'send_key': 'glue', + 'color': 58, + 'device_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, + ], + 'volume': 0.68, + }, + ] + + if profile_name == 'warehouse': + returns[0]['name'] = 'MCP BUNKER' + returns[0]['device_chain'] = [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 7200.0, 'Dry/Wet': 0.22}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, + ] + returns[1]['name'] = 'MCP DUB' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8200.0, 'Dry/Wet': 0.14}}, + ] + returns[2]['device_chain'][0]['parameters']['Drive'] = 5.5 + returns[2]['volume'] = 0.66 + elif profile_name == 'festival': + returns[0]['name'] = 'MCP WIDE' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 1.14}}, + ] + returns[1]['name'] = 'MCP TAIL' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, + ] + returns[0]['volume'] = 0.72 + returns[1]['volume'] = 0.68 + elif profile_name == 'swing': + returns[0]['name'] = 'MCP ROOM' + returns[1]['name'] = 'MCP SLAP' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.1}}, + ] + returns[2]['volume'] = 0.58 + elif profile_name == 'jackin': + returns[0]['name'] = 'MCP CLUB' + returns[1]['name'] = 'MCP SWING' + returns[2]['device_chain'][0]['parameters']['Drive'] = 3.8 + returns[3]['volume'] = 0.72 + elif profile_name == 'tech-house-club': + # Short reverb, mono delay, wide FX for club tech-house + returns[0]['name'] = 'REVERB SHORT' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.6}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8400.0, 'Dry/Wet': 0.08}}, + ] + returns[0]['volume'] = 0.70 + returns[1]['name'] = 'DELAY MONO' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.0}}, + {'device': 'Utility', 'parameters': {'Width': 0.0}}, + ] + returns[1]['volume'] = 0.68 + returns[2]['name'] = 'DRIVE HOT' + returns[2]['device_chain'][0]['parameters']['Drive'] = 4.0 + returns[2]['volume'] = 0.64 + returns[3]['name'] = 'GLUE BUS' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -16.5 + returns[3]['volume'] = 0.70 + elif profile_name == 'tech-house-deep': + # Deep minimal returns with subtle processing + returns[0]['name'] = 'REVERB DEEP' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 1.2}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 6200.0, 'Dry/Wet': 0.12}}, + ] + returns[0]['volume'] = 0.72 + returns[1]['name'] = 'DELAY DEEP' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Feedback': 0.45}}, + ] + returns[1]['volume'] = 0.64 + returns[2]['name'] = 'SATURATE DEEP' + returns[2]['device_chain'][0]['parameters']['Drive'] = 2.5 + returns[2]['volume'] = 0.56 + returns[3]['name'] = 'GLUE MINIMAL' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -20.0 + returns[3]['volume'] = 0.62 + elif profile_name == 'tech-house-funky': + # Groovy returns with modulation and swing + returns[0]['name'] = 'REVERB GROOVE' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.8}}, + {'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.08}}, + ] + returns[0]['volume'] = 0.74 + returns[1]['name'] = 'DELAY GROOVE' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.4, 'Feedback': 0.35}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8000.0, 'Dry/Wet': 0.1}}, + ] + returns[1]['volume'] = 0.70 + returns[2]['name'] = 'DRIVE FUNK' + returns[2]['device_chain'][0]['parameters']['Drive'] = 3.2 + returns[2]['device_chain'].append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) + returns[2]['volume'] = 0.60 + returns[3]['name'] = 'GLUE SWING' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -15.5 + returns[3]['volume'] = 0.72 + + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + returns[1]['name'] = 'MCP VOX ECHO' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 10800.0, 'Dry/Wet': 0.12}}, + ] + returns[0]['volume'] = max(0.68, float(returns[0]['volume']) - 0.04) + if 'industrial' in style_text: + returns[2]['name'] = 'MCP DRIVE' + returns[2]['device_chain'][0]['parameters']['Drive'] = max( + 4.8, + float(returns[2]['device_chain'][0]['parameters'].get('Drive', 4.5)) + ) + returns[3]['name'] = 'MCP BUS' + + return returns + + def _build_master_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + + # Start with default calibration values + calibration = dict(MASTER_CALIBRATION.get('default', {})) + + # Find matching profile (case-insensitive, partial match) + matched_profile = 'default' + profile_name_lower = profile_name.lower() + for cal_key in MASTER_CALIBRATION.keys(): + if cal_key.lower() in profile_name_lower or profile_name_lower in cal_key.lower(): + # Merge profile-specific values over defaults + profile_cal = MASTER_CALIBRATION[cal_key] + calibration.update(profile_cal) + matched_profile = cal_key + break + + # Track which profile was used + self._master_profile_used = matched_profile + + # Build master with calibrated values + # Master chain: Utility (gain staging) -> Saturator (color) -> Compressor (glue) -> Limiter (ceiling) + # Target: -1dB peak before limiter, -0.3dBFS ceiling after limiter + master = { + 'volume': calibration.get('volume', 0.85), + 'device_chain': [ + { + 'device': 'Utility', + 'parameters': { + 'Gain': calibration.get('utility_gain', -0.5), + 'Stereo Width': calibration.get('stereo_width', 1.04), + } + }, + { + 'device': 'Saturator', + 'parameters': {'Drive': calibration.get('saturator_drive', 0.12)} + }, + { + 'device': 'Compressor', + 'parameters': { + 'Ratio': calibration.get('compressor_ratio', 0.50), + 'Attack': calibration.get('compressor_attack', 0.30), + 'Release': calibration.get('compressor_release', 0.20), + } + }, + { + 'device': 'Limiter', + 'parameters': { + 'Gain': calibration.get('limiter_gain', 0.8), + 'Ceiling': calibration.get('limiter_ceiling', -0.3), + } + }, + ], + } + + # Apply style-based limiter_gain_factor from STYLE_GAIN_ADJUSTMENTS + for style_key, style_adj in STYLE_GAIN_ADJUSTMENTS.items(): + if style_key.lower() in style_text: + limiter_factor = style_adj.get('limiter_gain_factor') + if limiter_factor is not None: + master['device_chain'][3]['parameters']['Gain'] *= limiter_factor + break + + if 'industrial' in style_text: + master['device_chain'][1]['parameters']['Drive'] = max( + 0.8, + float(master['device_chain'][1]['parameters'].get('Drive', 0.3)) + ) + master['device_chain'][2]['parameters']['Ratio'] = max( + 0.7, + float(master['device_chain'][2]['parameters'].get('Ratio', 0.62)) + ) + + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + master['device_chain'][0]['parameters']['Stereo Width'] = max( + 1.14, + float(master['device_chain'][0]['parameters'].get('Stereo Width', 1.1)) + ) + master['device_chain'][3]['parameters']['Gain'] = max( + 0.1, + float(master['device_chain'][3]['parameters'].get('Gain', 0.0)) + ) + + return master + + def _apply_role_gain_calibration(self, role: str, base_volume: float) -> Dict[str, float]: + """ + Apply ROLE_GAIN_CALIBRATION to a role's volume. + + Args: + role: The role name (e.g., 'kick', 'bass', 'clap') + base_volume: The base volume from ROLE_MIX + + Returns: + Dict with 'volume' and optionally 'saturator_drive' if calibrated + """ + if role not in ROLE_GAIN_CALIBRATION: + return {'volume': base_volume} + + calibration = ROLE_GAIN_CALIBRATION[role] + calibrated_volume = float(calibration.get('volume', base_volume)) + + # Apply peak_reduction if present + peak_reduction = calibration.get('peak_reduction', 0.0) + if peak_reduction > 0: + calibrated_volume *= (1.0 - float(peak_reduction)) + self._peak_reductions_count += 1 + + result = {'volume': round(max(0.0, min(1.0, calibrated_volume)), 3)} + + # Include saturator_drive if present in calibration + if 'saturator_drive' in calibration: + result['saturator_drive'] = float(calibration['saturator_drive']) + + self._gain_calibration_overrides_count += 1 + + return result + + def _shape_mix_profile(self, role: str, mix_profile: Dict[str, Any], profile: Dict[str, Any], style: str) -> Dict[str, Any]: + shaped = { + 'volume': float(mix_profile.get('volume', 0.72)), + 'pan': float(mix_profile.get('pan', 0.0)), + 'sends': dict(mix_profile.get('sends', {})), + } + + # Apply ROLE_GAIN_CALIBRATION if available - overrides base volume + calibration = self._apply_role_gain_calibration(role, shaped['volume']) + if calibration.get('volume') is not None: + shaped['volume'] = calibration['volume'] + if calibration.get('saturator_drive') is not None: + shaped['saturator_drive'] = calibration['saturator_drive'] + + profile_name = str(profile.get('name', 'default')).lower() + pan_width = float(profile.get('pan_width', 0.16) or 0.16) + style_text = str(style or '').lower() + + if role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pluck', 'arp', 'counter', 'vocal']: + shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * (1.0 + pan_width))) + + if profile_name == 'warehouse': + if role in ['kick', 'bass', 'sub_bass']: + shaped['volume'] *= 1.03 + if role in ['pad', 'drone', 'atmos']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.88 + if role in ['reverse_fx', 'riser', 'impact']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.08) + elif profile_name == 'festival': + if role in ['lead', 'chords', 'pad', 'arp', 'vocal']: + shaped['volume'] *= 1.04 + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.15 + if role in ['kick', 'clap']: + shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.12) + elif profile_name == 'swing': + if role in ['perc', 'top_loop', 'ride', 'vocal', 'pluck']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.14 + if role in ['kick', 'sub_bass']: + shaped['volume'] *= 0.98 + elif profile_name == 'jackin': + if role in ['clap', 'perc', 'vocal', 'counter']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.08 + if role in ['top_loop', 'ride']: + shaped['volume'] *= 1.03 + elif profile_name == 'tech-house-club': + # Club-oriented: punchy drums, present vocals, tight bass + if role in ['kick', 'clap']: + shaped['volume'] *= 1.02 + shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.10) + if role in ['bass', 'sub_bass']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.06) + if role in ['vocal', 'counter']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.10 + if role in ['hat_open', 'top_loop', 'ride']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.92 + elif profile_name == 'tech-house-deep': + # Deep minimal: subtle processing, wide stereo + if role in ['kick', 'sub_bass']: + shaped['volume'] *= 0.98 + if role in ['pad', 'drone', 'atmos', 'chords']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.12 + if role in ['perc', 'top_loop']: + shaped['volume'] *= 0.95 + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 0.88 + elif profile_name == 'tech-house-funky': + # Funky groove: wider pan, more echo, bouncy feel + if role in ['perc', 'top_loop', 'ride']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.18 + if role in ['bass', 'sub_bass']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.05) + if role in ['vocal', 'pluck', 'arp']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.08 + if role in ['clap', 'hat_closed']: + shaped['volume'] *= 1.02 + + if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.12 + shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * 1.08)) + if 'industrial' in style_text and role in ['kick', 'bass', 'stab', 'impact', 'riser']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.09) + + shaped['volume'] = round(max(0.0, min(1.0, shaped['volume'])), 3) + shaped['pan'] = round(max(-1.0, min(1.0, shaped['pan'])), 3) + shaped['sends'] = { + send_key: round(max(0.0, min(1.0, float(send_value))), 3) + for send_key, send_value in shaped['sends'].items() + } + return shaped + + def _shape_role_fx_chain(self, role: str, profile: Dict[str, Any], style: str) -> List[Dict[str, Any]]: + chain = [dict(item) for item in ROLE_FX_CHAINS.get(role, [])] + profile_name = str(profile.get('name', 'default')).lower() + style_text = str(style or '').lower() + + if profile_name == 'warehouse': + if role in ['kick', 'bass', 'stab']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -18.0}}) + if role in ['atmos', 'drone', 'pad']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 7600.0, 'Dry/Wet': 0.14}}) + elif profile_name == 'festival': + if role in ['lead', 'arp', 'vocal']: + chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.1}}) + if role in ['chords', 'pad']: + chain.append({'device': 'Utility', 'parameters': {'Width': 140.0}}) + elif profile_name == 'swing': + if role in ['perc', 'top_loop', 'ride', 'vocal']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) + elif profile_name == 'jackin': + if role in ['clap', 'perc', 'vocal', 'counter']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.5}}) + elif profile_name == 'tech-house-club': + # Club: punchy drums, saturated bass, crisp tops + if role in ['kick', 'clap']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -16.0, 'Attack': 0.02}}) + if role in ['bass', 'sub_bass']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 2.0}}) + if role in ['hat_closed', 'hat_open', 'top_loop']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.12}}) + if role in ['vocal', 'counter']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) + elif profile_name == 'tech-house-deep': + # Deep: subtle saturation, atmospheric processing + if role in ['kick', 'bass']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -20.0}}) + if role in ['pad', 'drone', 'atmos']: + chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}) + if role in ['chords', 'pluck']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 9200.0, 'Dry/Wet': 0.08}}) + elif profile_name == 'tech-house-funky': + # Funky: groove-enhancing FX, modulation + if role in ['perc', 'top_loop', 'ride']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.10, 'Ping Pong': 0.3}}) + if role in ['bass', 'sub_bass']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) + if role in ['vocal', 'pluck', 'arp']: + chain.append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) + if role in ['clap', 'hat_closed']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.2}}) + + if 'industrial' in style_text and role in ['kick', 'bass', 'impact', 'riser']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) + if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 11200.0, 'Dry/Wet': 0.1}}) + + return chain + + def _get_section_drum_variant(self, role: str, section: Dict[str, Any]) -> str: + """Get appropriate drum variant for section and role with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + role_lower = role.lower() + + if role_lower not in DRUM_SECTION_VARIANTS.get(kind, {}): + return 'straight' + + variants = list(DRUM_SECTION_VARIANTS[kind][role_lower]) + valid_variants = [v for v in variants if v in DRUM_PATTERN_BANKS.get(role_lower, {})] + if not valid_variants and role_lower in DRUM_PATTERN_BANKS: + valid_variants = list(DRUM_PATTERN_BANKS[role_lower].keys()) + + if not valid_variants: + return 'straight' + + rng = self._section_rng(section, role, salt=1) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('drum', f'{role_lower}_{v}') + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] + + _record_pattern_variant_usage('drum', f'{role_lower}_{chosen}') + return chosen + + def _generate_drum_pattern_from_bank(self, role: str, variant: str, + section_length: float, + velocity_base: int = 100) -> List[Dict[str, Any]]: + """Generate drum pattern from pattern bank.""" + role_lower = role.lower() + + if role_lower not in DRUM_PATTERN_BANKS: + return [] + + bank = DRUM_PATTERN_BANKS[role_lower] + if variant not in bank: + variant = list(bank.keys())[0] # Fallback to first + + positions = bank[variant] + notes = [] + + # Determine pitch based on role + pitch_map = { + 'kick': 36, 'clap': 39, 'hat_closed': 42, + 'hat_open': 46, 'perc': 50, 'ride': 51 + } + pitch = pitch_map.get(role_lower, 36) + + for pos in positions: + # Repeat pattern for each bar + for bar in range(int(section_length // 4)): + start = pos + (bar * 4.0) + if start < section_length: + # Add slight velocity variation + velocity = max(60, min(127, velocity_base + random.randint(-10, 10))) + duration = 0.1 if role_lower in ['hat_closed', 'hat_open', 'ride'] else 0.15 + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated drum pattern from bank: role={role}, variant={variant}, notes={len(notes)}") + return notes + + def _get_section_bass_variant(self, section: Dict[str, Any]) -> str: + """Get appropriate bass variant for section with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + + if kind not in BASS_SECTION_VARIANTS: + return 'anchor' + + variants = list(BASS_SECTION_VARIANTS[kind]) + valid_variants = [v for v in variants if v in BASS_PATTERN_BANKS] + if not valid_variants: + valid_variants = list(BASS_PATTERN_BANKS.keys()) + + rng = self._section_rng(section, 'bass', salt=2) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('bass', v) + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] if valid_variants else 'anchor' + + _record_pattern_variant_usage('bass', chosen) + return chosen + + def _compute_section_signature(self, section: Dict[str, Any]) -> str: + """Compute a signature for section to detect repetition.""" + section = self._ensure_section_pattern_variants(section) + signature_parts = [] + drum_role_variants = dict(section.get('drum_role_variants') or {}) + + signature_parts.append(f"kick:{drum_role_variants.get('kick', section.get('drum_variant', 'default'))}") + signature_parts.append(f"clap:{drum_role_variants.get('clap', section.get('drum_variant', 'default'))}") + signature_parts.append(f"hat:{drum_role_variants.get('hat_closed', section.get('drum_variant', 'default'))}") + signature_parts.append(f"bass:{section.get('bass_bank_variant', section.get('bass_variant', 'default'))}") + signature_parts.append(f"lead:{section.get('melodic_bank_variant', section.get('melodic_variant', 'default'))}") + signature_parts.append(f"fill:{section.get('transition_fill', 'none')}") + + # Add density and swing + density = section.get('density', 1.0) + swing = section.get('swing', 0.0) + signature_parts.append(f"d:{density:.1f}") + signature_parts.append(f"s:{swing:.2f}") + + return "|".join(signature_parts) + + def _check_section_repetition(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Check and warn about excessive section repetition.""" + signatures = [] + consecutive_same = 0 + max_consecutive = 2 + + for i, section in enumerate(sections): + self._ensure_section_pattern_variants(section) + sig = self._compute_section_signature(section) + + if signatures and signatures[-1] == sig: + consecutive_same += 1 + if consecutive_same >= max_consecutive: + logger.warning("REPETITION: %d consecutive sections with same signature: %s", + consecutive_same + 1, sig) + self._force_section_pattern_variation(section) + sig = self._compute_section_signature(section) + else: + consecutive_same = 0 + + signatures.append(sig) + + return sections + + def _record_section_variant(self, section: Dict[str, Any], role: str, variant: str): + """Record variant used for a role in a section.""" + key = f'{role}_variant' + section[key] = variant + + def _choose_alternate_variant(self, options: List[str], current: Optional[str], rng: random.Random) -> Optional[str]: + ordered: List[str] = [] + for option in options: + if option not in ordered: + ordered.append(option) + if not ordered: + return current + alternatives = [option for option in ordered if option != current] + if not alternatives: + return current or ordered[0] + return rng.choice(alternatives) + + def _ensure_section_pattern_variants(self, section: Dict[str, Any]) -> Dict[str, Any]: + _kind = str(section.get('kind', 'drop')).lower() # noqa: F841 - used by helper methods via section dict + drum_role_variants = dict(section.get('drum_role_variants') or {}) + for role in ['kick', 'clap', 'hat_closed', 'hat_open', 'perc', 'ride']: + if role in drum_role_variants: + continue + variant = self._get_section_drum_variant(role, section) + if variant in DRUM_PATTERN_BANKS.get(role, {}): + drum_role_variants[role] = variant + self._record_section_variant(section, role, variant) + section['drum_role_variants'] = drum_role_variants + + bass_bank_variant = str(section.get('bass_bank_variant', '') or '') + if bass_bank_variant not in BASS_PATTERN_BANKS: + bass_bank_variant = self._get_section_bass_variant(section) + section['bass_bank_variant'] = bass_bank_variant + self._record_section_variant(section, 'bass_bank', str(section.get('bass_bank_variant', 'anchor'))) + + melodic_bank_variant = str(section.get('melodic_bank_variant', '') or '') + if melodic_bank_variant not in MELODIC_PATTERN_BANKS: + melodic_bank_variant = self._get_section_melodic_variant(section) + section['melodic_bank_variant'] = melodic_bank_variant + self._record_section_variant(section, 'melodic_bank', str(section.get('melodic_bank_variant', 'motif'))) + section.setdefault('pattern_variant_ready', True) + return section + + def _force_section_pattern_variation(self, section: Dict[str, Any]) -> Dict[str, Any]: + kind = str(section.get('kind', 'drop')).lower() + self._ensure_section_pattern_variants(section) + drum_role_variants = dict(section.get('drum_role_variants') or {}) + + for role in ['kick', 'clap', 'hat_closed']: + options = DRUM_SECTION_VARIANTS.get(kind, {}).get(role, []) + current = drum_role_variants.get(role) + next_variant = self._choose_alternate_variant(options, current, self._section_rng(section, role, salt=101)) + if next_variant: + drum_role_variants[role] = next_variant + self._record_section_variant(section, role, next_variant) + section['drum_role_variants'] = drum_role_variants + + bass_options = BASS_SECTION_VARIANTS.get(kind, []) + bass_variant = self._choose_alternate_variant( + bass_options, + str(section.get('bass_bank_variant', '') or ''), + self._section_rng(section, 'bass', salt=102), + ) + if bass_variant: + section['bass_bank_variant'] = bass_variant + self._record_section_variant(section, 'bass_bank', bass_variant) + + melodic_options = MELODIC_SECTION_VARIANTS.get(kind, []) + melodic_variant = self._choose_alternate_variant( + melodic_options, + str(section.get('melodic_bank_variant', '') or ''), + self._section_rng(section, 'melodic', salt=103), + ) + if melodic_variant: + section['melodic_bank_variant'] = melodic_variant + self._record_section_variant(section, 'melodic_bank', melodic_variant) + + return section + + def _generate_bass_pattern_from_bank(self, variant: str, key: str, + section_length: float, + velocity_base: int = 95) -> List[Dict[str, Any]]: + """Generate bass pattern from pattern bank.""" + if variant not in BASS_PATTERN_BANKS: + variant = 'anchor' + + bank = BASS_PATTERN_BANKS[variant] + positions = bank['positions'] + durations = bank['durations'] + style = bank.get('style', 'root') + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 2) + + notes = [] + for bar in range(int(section_length // 4)): + for i, pos in enumerate(positions): + start = pos + (bar * 4.0) + if start < section_length: + duration = durations[i] if i < len(durations) else 0.4 + velocity = max(70, min(120, velocity_base + random.randint(-8, 8))) + + # Adjust pitch based on style + pitch = root_midi + if style == 'ascending' and bar > 0: + pitch += min(bar, 5) # Rise over bars + elif style == 'syncopated' and i % 2 == 1: + pitch += 5 # Fifth on offbeats + + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated bass pattern from bank: variant={variant}, notes={len(notes)}") + return notes + + def _vary_drum_notes(self, notes: List[Dict[str, Any]], role: str, section: Dict[str, Any], + section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + role_variant = str((section.get('drum_role_variants') or {}).get(role, '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + density = float(section.get('density', 1.0)) + _ = int(section.get('energy', 1)) + variant = str(section.get('drum_variant', 'straight')).lower() + swing = float(section.get('swing', 0.0)) + tightness = float(self._current_generation_profile.get('drum_tightness', 1.0)) + rng = self._section_rng(section, role, salt=5) + + if role_variant in DRUM_PATTERN_BANKS.get(role, {}): + logger.debug(f"Using section pattern bank for {role} with variant {role_variant} in section {kind}") + bank_notes = self._generate_drum_pattern_from_bank(role, role_variant, section_length) + if bank_notes: + use_bank_prob = 0.85 if kind in ['intro', 'break', 'outro'] else 0.95 + if rng.random() < use_bank_prob or not notes: + return bank_notes + + if not notes: + if role in DRUM_PATTERN_BANKS: + all_variants = list(DRUM_PATTERN_BANKS[role].keys()) + if all_variants: + fallback_variant = rng.choice(all_variants) + return self._generate_drum_pattern_from_bank(role, fallback_variant, section_length) + return [] + + varied = list(notes) + + if variant == 'skip' and role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride']: + varied = self._apply_density_mask(varied, section, role, keep_probability=min(0.94, max(0.54, density - 0.08))) + elif variant == 'pressure' and role in ['kick', 'hat_closed', 'perc']: + pressure_notes = [] + for bar_start in range(0, int(section_length), 4): + if role == 'kick' and rng.random() > 0.35: + pressure_notes.append(self._make_note(36, min(section_length - 0.05, bar_start + 3.5), 0.12, 92)) + elif role == 'hat_closed' and rng.random() > 0.45: + pressure_notes.append(self._make_note(42, min(section_length - 0.05, bar_start + 3.75), 0.06, 58)) + elif role == 'perc' and rng.random() > 0.5: + pressure_notes.append(self._make_note(50, min(section_length - 0.05, bar_start + 3.25), 0.12, 74)) + varied = self._merge_section_notes(varied, pressure_notes, section_length) + elif variant == 'shuffle' and role not in ['kick', 'clap', 'sc_trigger', 'crash']: + varied = self._apply_swing(varied, swing or (0.035 / max(0.8, tightness)), section_length) + + if swing > 0.0 and role in ['top_loop', 'perc', 'ride']: + varied = self._apply_swing(varied, swing * 0.55, section_length) + + return varied + + def _vary_bass_notes(self, notes: List[Dict[str, Any]], role: str, key: str, + section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + bank_variant = str(section.get('bass_bank_variant', '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + variant = str(section.get('bass_variant', 'anchor')).lower() + + if bank_variant in BASS_PATTERN_BANKS: + logger.debug(f"Using section bass pattern bank for variant {bank_variant} in section {kind}") + return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) + + if not notes: + if bank_variant in BASS_PATTERN_BANKS: + return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) + all_variants = list(BASS_PATTERN_BANKS.keys()) + if all_variants: + rng = self._section_rng(section, role, salt=7) + fallback = rng.choice(all_variants) + return self._generate_bass_pattern_from_bank(fallback, key, section_length) + return [] + + profile_motion = str(self._current_generation_profile.get('bass_motion', 'locked')).lower() + rng = self._section_rng(section, role, salt=7) + root_note = key[:-1] if len(key) > 1 else key + scale_name = 'minor' if 'm' in key.lower() else 'major' + root_midi = self.note_name_to_midi(root_note, 2) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + varied = [] + for index, note in enumerate(notes): + pitch = int(note['pitch']) + start = float(note['start']) + duration = float(note['duration']) + velocity = int(note['velocity']) + + if variant == 'anchor' and (start % 4.0) < 0.001: + pitch = root_midi + duration = max(duration, 0.5) + elif variant == 'bounce' and (start % 1.0) >= 0.5: + velocity = min(124, velocity + 8) + duration = max(0.18, duration * 0.82) + elif variant == 'syncopated' and (start % 1.0) < 0.001 and rng.random() > 0.4: + start = min(section_length - 0.05, start + 0.25) + duration = max(0.16, duration * 0.68) + elif variant == 'pedal' and index % 3 == 0: + pitch = root_midi + + if profile_motion == 'lifted' and index % 8 == 6: + pitch += 12 + elif profile_motion == 'syncopated' and rng.random() > 0.72: + pitch = scale_notes[(index + 4) % len(scale_notes)] + elif profile_motion == 'bouncy' and (start % 4.0) >= 2.0: + velocity = min(124, velocity + 5) + + varied.append(self._make_note(pitch, start, duration, velocity)) + + return self._shape_notes_for_section(varied, kind, role, section_length) + + def _get_section_melodic_variant(self, section: Dict[str, Any]) -> str: + """Get appropriate melodic variant for section with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + + if kind not in MELODIC_SECTION_VARIANTS: + return 'motif' + + variants = list(MELODIC_SECTION_VARIANTS[kind]) + valid_variants = [v for v in variants if v in MELODIC_PATTERN_BANKS] + if not valid_variants: + valid_variants = list(MELODIC_PATTERN_BANKS.keys()) + + rng = self._section_rng(section, 'melodic', salt=3) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('melodic', v) + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] if valid_variants else 'motif' + + _record_pattern_variant_usage('melodic', chosen) + return chosen + + def _generate_melodic_pattern_from_bank(self, variant: str, key: str, + scale_name: str, + section_length: float, + velocity_base: int = 90) -> List[Dict[str, Any]]: + """Generate melodic pattern from pattern bank.""" + if variant not in MELODIC_PATTERN_BANKS: + variant = 'motif' + + bank = MELODIC_PATTERN_BANKS[variant] + intervals = bank['intervals'] + rhythm = bank['rhythm'] + durations = bank['durations'] + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + notes = [] + for bar in range(int(section_length // 4)): + for i, pos in enumerate(rhythm): + start = pos + (bar * 4.0) + if start < section_length: + interval = intervals[i] if i < len(intervals) else intervals[-1] + pitch = scale_notes[interval % len(scale_notes)] + duration = durations[i] if i < len(durations) else 0.3 + velocity = max(60, min(110, velocity_base + random.randint(-10, 10))) + + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated melodic pattern from bank: variant={variant}, notes={len(notes)}") + return notes + + def _vary_melodic_notes(self, notes: List[Dict[str, Any]], role: str, key: str, scale_name: str, + section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + bank_variant = str(section.get('melodic_bank_variant', '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + + if bank_variant in MELODIC_PATTERN_BANKS: + logger.debug(f"Using section melodic pattern bank for variant {bank_variant} in section {kind}") + return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) + + if not notes: + if bank_variant in MELODIC_PATTERN_BANKS: + return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) + all_variants = list(MELODIC_PATTERN_BANKS.keys()) + if all_variants: + rng = self._section_rng(section, role, salt=11) + fallback = rng.choice(all_variants) + return self._generate_melodic_pattern_from_bank(fallback, key, scale_name, section_length) + return [] + + variant = str(section.get('melodic_variant', 'motif')).lower() + profile_motion = str(self._current_generation_profile.get('melodic_motion', 'restrained')).lower() + rng = self._section_rng(section, role, salt=11) + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + transformed = [] + for index, note in enumerate(notes): + start = float(note['start']) + pitch = int(note['pitch']) + duration = float(note['duration']) + velocity = int(note['velocity']) + keep = True + + if variant == 'response' and int(start / 2.0) % 2 == 0 and role in ['lead', 'pluck', 'counter']: + keep = False + elif variant == 'lift' and index % 4 == 3: + pitch += 12 + velocity = min(124, velocity + 10) + elif variant == 'descend' and index % 5 == 4: + pitch -= 12 + duration = max(0.16, duration * 0.9) + elif variant == 'drone': + keep = (start % 4.0) < 0.001 or duration >= 0.5 + if keep: + pitch = scale_notes[index % min(3, len(scale_notes))] + duration = max(duration, 1.2) + + if keep and profile_motion in ['anthemic', 'hooky'] and role in ['lead', 'arp', 'pluck']: + if rng.random() > 0.78: + pitch += 12 + elif profile_motion == 'hooky' and rng.random() > 0.84: + start = min(section_length - 0.05, start + 0.25) + + if keep and profile_motion == 'call_response' and role in ['counter', 'pluck'] and (start % 4.0) < 2.0: + velocity = max(52, velocity - 8) + + if keep: + transformed.append(self._make_note(pitch, start, duration, velocity)) + + if role in ['arp', 'pluck'] and float(section.get('swing', 0.0)) > 0.0: + transformed = self._apply_swing(transformed, float(section.get('swing', 0.0)) * 0.45, section_length) + + return self._shape_notes_for_section(transformed, kind, role, section_length) + + def _transpose_notes(self, notes: List[Dict[str, Any]], semitones: int) -> List[Dict[str, Any]]: + return [ + self._make_note(note['pitch'] + semitones, note['start'], note['duration'], note['velocity']) + for note in notes + ] + + def _scale_note_lengths(self, notes: List[Dict[str, Any]], factor: float, minimum: float = 0.1) -> List[Dict[str, Any]]: + scaled = [] + for note in notes: + scaled.append( + self._make_note( + note['pitch'], + note['start'], + max(minimum, float(note['duration']) * factor), + note['velocity'], + ) + ) + return scaled + + def _shape_notes_for_section(self, notes: List[Dict[str, Any]], section_kind: str, role: str, + section_length: float) -> List[Dict[str, Any]]: + if not notes: + return [] + + shaped = [] + for note in notes: + start = float(note['start']) + keep = True + + if section_kind in ['intro', 'outro'] and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter']: + keep = int(start * 2) % 4 == 0 + elif section_kind == 'break' and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter', 'clap', 'hat_open', 'ride']: + keep = int(start) % 4 == 0 + + if keep and start < section_length: + duration = min(float(note['duration']), section_length - start) + shaped.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + return shaped + + def _merge_section_notes(self, base_notes: List[Dict[str, Any]], extra_notes: List[Dict[str, Any]], + section_length: float) -> List[Dict[str, Any]]: + merged = [] + for note in list(base_notes) + list(extra_notes): + start = float(note['start']) + if start >= section_length: + continue + duration = min(float(note['duration']), max(0.05, section_length - start)) + merged.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + merged.sort(key=lambda item: (item['start'], item['pitch'])) + return merged + + def _build_drum_fill(self, role: str, section_length: float, intensity: int) -> List[Dict[str, Any]]: + fill_start = max(0.0, section_length - 1.0) + if role == 'kick' and intensity >= 3: + return [self._make_note(36, fill_start + step, 0.14, 112 + (idx % 2) * 8) for idx, step in enumerate([0.0, 0.25, 0.5, 0.75])] + if role == 'clap' and intensity >= 3: + return [self._make_note(39, fill_start + step, 0.18, 92 + idx * 6) for idx, step in enumerate([0.25, 0.5, 0.75])] + if role == 'hat_closed': + return [self._make_note(42, fill_start + (idx * 0.125), 0.06, 64 + (idx % 4) * 6) for idx in range(8)] + if role == 'perc' and intensity >= 2: + return [ + self._make_note(37, fill_start + 0.125, 0.08, 72), + self._make_note(47, fill_start + 0.375, 0.08, 76), + self._make_note(50, fill_start + 0.625, 0.1, 82), + ] + return [] + + def _build_turnaround_notes(self, key: str, scale_name: str, section_length: float, + octave: int, velocity: int = 92) -> List[Dict[str, Any]]: + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, octave) + scale_notes = self.get_scale_notes(root_midi, scale_name) + fill_start = max(0.0, section_length - 2.0) + degrees = [0, 2, 4, 6] + notes = [] + for index, degree in enumerate(degrees): + pitch = scale_notes[degree % len(scale_notes)] + notes.append(self._make_note(pitch, fill_start + (index * 0.5), 0.38, velocity + index * 4)) + return notes + + def _generate_fill_pattern(self, fill_name: str, start_offset: float) -> Tuple[List[Dict[str, Any]], List[str]]: + """ + Generate fill pattern at specified offset. + + Returns: + (notes, roles) - tuple of note list and list of roles used + """ + if fill_name not in FILL_PATTERNS: + return [], [] + + fill = FILL_PATTERNS[fill_name] + notes = [] + roles_used = [] + + pitch_map = { + 'kick': 36, 'snare': 38, 'hat': 42, 'hat_open': 46, + 'crash': 49, 'ride': 51, 'perc': 50 + } + + for role, positions in fill['pattern'].items(): + roles_used.append(role) + pitch = pitch_map.get(role, 50) + velocity = fill['velocities'].get(role, 90) + + for pos in positions: + start = start_offset + pos + duration = 0.1 if role in ['hat', 'hat_open', 'ride'] else 0.15 + notes.append(self._make_note(pitch, start, duration, velocity)) + + # Track materialization for debugging/logging + if not hasattr(self, '_transition_materialization_log'): + self._transition_materialization_log = [] + self._transition_materialization_log.append({ + 'fill': fill_name, + 'start': start_offset, + 'notes_count': len(notes), + 'roles': roles_used + }) + + return notes, roles_used + + def _generate_transition_events(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Generate fill and transition events between sections.""" + transition_events = [] + + # Calculate start positions for each section + arrangement_time = 0.0 + for section in sections: + section['start'] = arrangement_time + arrangement_time += float(section.get('beats', 0.0) or 0.0) + + for i, section in enumerate(sections): + kind = str(section.get('kind', '')).lower() + start = float(section.get('start', 0.0)) + length = float(section.get('beats', 8.0)) + end = start + length + + # Check for transition to next section + if i < len(sections) - 1: + next_kind = str(sections[i + 1].get('kind', '')).lower() + transition_key = (kind, next_kind) + + if transition_key in TRANSITION_EVENTS: + fills = TRANSITION_EVENTS[transition_key] + rng = self._section_rng(section, 'transition', salt=20) + fill_name = rng.choice(fills) + + # Get notes and roles from fill pattern + fill_notes, fill_roles = self._generate_fill_pattern(fill_name, end - 2.0) + + transition_events.append({ + 'fill': fill_name, + 'start': end - 2.0, + 'section_kind': kind, + 'next_section_kind': next_kind, + 'roles': fill_roles, + 'notes': fill_notes, # Include actual notes for materialization + 'notes_count': len(fill_notes) + }) + logger.debug("TRANSITION: Added '%s' at %.1f for %s->%s", + fill_name, end - 2.0, kind, next_kind) + + return transition_events + + def _apply_transition_density_rules(self, transition_events: List[Dict], + sections: List[Dict]) -> List[Dict]: + """ + Apply anti-overcrowding rules to transition events. + + Returns filtered list of events. + """ + if not transition_events: + return [] + + filtered = [] + last_event_time = {} # Track last time of each event type + section_fill_counts = defaultdict(int) # Track fills per section + + for event in transition_events: + fill_name = event.get('fill', '') + start = event.get('start', 0.0) + section_kind = event.get('section_kind', 'drop') + + # Rule 1: Max fills per section + max_fills = TRANSITION_DENSITY_RULES['max_fills_by_section'].get(section_kind, 2) + if section_fill_counts[section_kind] >= max_fills: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - section '%s' at max (%d fills)", + fill_name, section_kind, max_fills) + continue + + # Rule 2: Minimum distance between same-type events + min_dist = TRANSITION_DENSITY_RULES['min_distance_same_type'].get(fill_name, 0) + if fill_name in last_event_time: + time_since_last = start - last_event_time[fill_name] + if time_since_last < min_dist: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - too close to previous (%.1f < %.1f)", + fill_name, time_since_last, min_dist) + continue + + # Rule 3: Check for exclusive events at same position + skip = False + for existing in filtered: + if abs(existing.get('start', -999) - start) < 0.5: # Same position + for exclusive_set in TRANSITION_DENSITY_RULES['exclusive_events']: + if fill_name in exclusive_set and existing.get('fill') in exclusive_set: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - exclusive with '%s' at %.1f", + fill_name, existing.get('fill'), start) + skip = True + break + if skip: + break + + if skip: + continue + + # Event passes all rules + filtered.append(event) + last_event_time[fill_name] = start + section_fill_counts[section_kind] += 1 + + logger.info("TRANSITION_DENSITY: %d events passed filtering (from %d original)", + len(filtered), len(transition_events)) + + return filtered + + def _transition_events_to_notes(self, transition_events: List[Dict]) -> List[Dict]: + """Convert filtered transition events to MIDI notes.""" + notes = [] + for event in transition_events: + fill_name = event.get('fill', '') + start = event.get('start', 0.0) + fill_notes, _ = self._generate_fill_pattern(fill_name, start) + notes.extend(fill_notes) + return notes + + def _materialize_transition_events(self, config: Dict[str, Any], + track_blueprints: List[Dict]) -> List[Dict]: + """ + Materialize transition events into track blueprints. + + Adds actual MIDI notes to transition-oriented tracks based on transition_events config. + """ + transition_events = config.get('transition_events', []) + if not transition_events: + config['transition_materialization'] = { + 'events_count': 0, + 'materialized': False, + 'note_count': 0, + 'track_roles': [], + } + return track_blueprints + + transition_track_targets = { + 'drum_fill_4bar': 'snare_fill', + 'drum_fill_2bar': 'snare_fill', + 'snare_roll': 'snare_fill', + 'hat_open_build': 'riser', + 'kick_drop': 'impact', + 'crash_impact': 'crash', + } + pitch_to_track_role = { + 36: 'kick', + 38: 'snare_fill', + 42: 'hat_closed', + 46: 'hat_open', + 49: 'crash', + 50: 'perc', + 51: 'ride', + } + + # Build a lookup dict of tracks by role + tracks_by_role = {} + for track in track_blueprints: + role = track.get('role', '') + if role: + tracks_by_role[role] = track + + # Track what was materialized + materialized_count = 0 + materialized_track_roles: set = set() + + # Materialize each transition event + for event in transition_events: + fill_name = event.get('fill', '') + fill_start = event.get('start', 0.0) + fill_notes = event.get('notes', []) + + if not fill_notes: + event['materialized'] = False + event['materialized_notes_count'] = 0 + event['materialized_track_roles'] = [] + continue + + preferred_track_role = transition_track_targets.get(fill_name) + preferred_note_map: Dict[str, List[Dict[str, Any]]] = {} + if preferred_track_role and preferred_track_role in tracks_by_role: + preferred_note_map[preferred_track_role] = list(fill_notes) + + fallback_note_map: Dict[str, List[Dict[str, Any]]] = {} + for note in fill_notes: + note_role = pitch_to_track_role.get(int(note.get('pitch', 0))) + if note_role: + fallback_note_map.setdefault(note_role, []).append(note) + + # Add notes to appropriate tracks + event_materialized_count = 0 + event_track_roles: set = set() + + for notes_by_track_role in [preferred_note_map, fallback_note_map]: + if not notes_by_track_role: + continue + + for track_role, notes_to_add in notes_by_track_role.items(): + if track_role not in tracks_by_role: + logger.debug("TRANSITION_MATERIALIZATION: No track for role '%s', skipping %d notes", + track_role, len(notes_to_add)) + continue + if track_role in event_track_roles: + continue + + track = tracks_by_role[track_role] + clips = track.get('clips', []) + + for clip in clips: + clip_scene_index = clip.get('scene_index', -1) + sections = config.get('sections', []) + if clip_scene_index < 0 or clip_scene_index >= len(sections): + continue + + section = sections[clip_scene_index] + section_start = float(section.get('start', 0.0)) + section_beats = float(section.get('beats', 0.0)) + + if section_start <= fill_start < section_start + section_beats: + existing_notes = clip.get('notes', []) + adjusted_notes = [] + for note in notes_to_add: + adjusted_note = dict(note) + adjusted_note['start'] = note['start'] - section_start + adjusted_notes.append(adjusted_note) + + existing_notes.extend(adjusted_notes) + existing_notes.sort(key=lambda item: (float(item.get('start', 0.0)), int(item.get('pitch', 0)))) + clip['notes'] = existing_notes + materialized_count += len(adjusted_notes) + event_materialized_count += len(adjusted_notes) + materialized_track_roles.add(track_role) + event_track_roles.add(track_role) + + logger.debug("TRANSITION_MATERIALIZATION: Added %d notes to track '%s' (role: %s) for fill '%s' at %.1f", + len(adjusted_notes), track.get('name', ''), track_role, fill_name, fill_start) + break + + if event_materialized_count > 0: + break + + event['materialized'] = event_materialized_count > 0 + event['materialized_notes_count'] = event_materialized_count + event['materialized_track_roles'] = sorted(event_track_roles) + + logger.info("TRANSITION_MATERIALIZATION: Total %d notes materialized across all tracks", materialized_count) + config['transition_materialization'] = { + 'events_count': len(transition_events), + 'materialized': materialized_count > 0, + 'note_count': materialized_count, + 'track_roles': sorted(materialized_track_roles), + } + return track_blueprints + + def _find_reference_track_profile(self) -> Optional[Dict[str, Any]]: + matches: List[Tuple[float, Dict[str, Any]]] = [] + audio_extensions = {'.wav', '.mp3', '.aiff', '.flac', '.aif', '.ogg'} + for directory in REFERENCE_SEARCH_DIRS: + if not directory.exists(): + continue + for candidate in sorted(directory.glob('*')): + if not candidate.is_file(): + continue + if candidate.suffix.lower() not in audio_extensions: + continue + normalized_name = candidate.name.lower() + for profile in REFERENCE_TRACK_PROFILES: + if all(term in normalized_name for term in profile.get('match_terms', [])): + resolved = dict(profile) + resolved['path'] = str(candidate) + resolved['file_name'] = candidate.name + try: + modified = float(candidate.stat().st_mtime) + except Exception: + modified = 0.0 + matches.append((modified, resolved)) + + if not matches: + return None + matches.sort(key=lambda item: item[0], reverse=True) + return matches[0][1] + + def _resolve_reference_track_profile(self, genre: str, style: str, bpm: float, + key: str, structure: str, + reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, Any]]: + profile = self._find_reference_track_profile() + if not profile: + return None + + target_genre = profile.get('genre', '') + compatible_genres = {target_genre, 'techno', 'tech-house', 'house'} + if genre and genre not in compatible_genres: + return None + + if bpm <= 0: + bpm = float(profile.get('bpm', bpm or 0)) + if not key: + key = profile.get('key', key) + if not style: + style = profile.get('style', style) + if not structure or structure == 'standard': + structure = profile.get('structure', structure or 'standard') + + result = { + 'genre': target_genre or genre, + 'style': style, + 'bpm': bpm, + 'key': key, + 'structure': structure, + 'reference': profile, + } + + # Forward energy profile if available + if reference_energy_profile: + result['reference_energy_profile'] = reference_energy_profile + + return result + + def _build_return_states(self, returns: List[Dict[str, Any]], section: Dict[str, Any]) -> List[Dict[str, Any]]: + if not returns: + return [] + + kind = str(section.get('kind', 'drop')).lower() + energy = max(1, int(section.get('energy', 1))) + profile_name = str(self._current_generation_profile.get('name', 'default')).lower() + style_text = str(self._current_generation_profile.get('style_text', '')).lower() + + volume_factors = { + 'space': {'intro': 0.94, 'build': 0.84, 'drop': 0.7, 'break': 1.02, 'outro': 0.86}, + 'echo': {'intro': 0.8, 'build': 1.04, 'drop': 0.72, 'break': 0.92, 'outro': 0.78}, + 'heat': {'intro': 0.56, 'build': 0.88, 'drop': 1.06, 'break': 0.42, 'outro': 0.66}, + 'glue': {'intro': 0.72, 'build': 0.86, 'drop': 1.02, 'break': 0.58, 'outro': 0.74}, + } + space_mix = {'intro': 0.94, 'build': 0.88, 'drop': 0.8, 'break': 1.0, 'outro': 0.9} + echo_mix = {'intro': 0.72, 'build': 0.92, 'drop': 0.62, 'break': 0.84, 'outro': 0.76} + width_targets = {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.16, 'outro': 1.04} + filter_factors = {'intro': 0.86, 'build': 1.0, 'drop': 1.18, 'break': 0.78, 'outro': 0.9} + drive_offsets = {'intro': -1.2, 'build': 0.2, 'drop': 1.0, 'break': -1.6, 'outro': -0.5} + threshold_offsets = {'intro': 1.5, 'build': -0.5, 'drop': -2.0, 'break': 2.5, 'outro': 1.0} + + states = [] + for return_index, return_spec in enumerate(returns): + send_key = str(return_spec.get('send_key', return_spec.get('name', ''))).strip().lower() + if not send_key: + continue + + base_volume = float(return_spec.get('volume', 0.7)) + volume_factor = volume_factors.get(send_key, {}).get(kind, 1.0) + if send_key in ['heat', 'glue'] and energy >= 4: + volume_factor += 0.04 + elif send_key in ['space', 'echo'] and kind == 'break': + volume_factor += 0.04 + + if profile_name == 'warehouse' and send_key == 'heat': + volume_factor += 0.05 + elif profile_name == 'festival' and send_key == 'space': + volume_factor += 0.06 + elif profile_name == 'swing' and send_key == 'echo': + volume_factor += 0.05 + elif profile_name == 'jackin' and send_key == 'glue': + volume_factor += 0.05 + + if 'industrial' in style_text and send_key == 'heat': + volume_factor += 0.05 + if 'latin' in style_text and send_key == 'echo': + volume_factor += 0.06 + + state = { + 'return_index': return_index, + 'send_key': send_key, + 'volume': self._clamp_unit(base_volume * volume_factor), + 'device_parameters': [], + } + + for device_index, device_spec in enumerate(return_spec.get('device_chain', []) or []): + if not isinstance(device_spec, dict): + continue + device_name = str(device_spec.get('device', '') or '').strip() + if not device_name: + continue + device_name_lower = device_name.lower() + base_parameters = dict(device_spec.get('parameters', {})) + parameter_updates = {} + + if send_key == 'space': + if 'hybrid reverb' in device_name_lower: + parameter_updates['Dry/Wet'] = space_mix.get(kind, 0.9) + elif 'auto filter' in device_name_lower: + base_frequency = float(base_parameters.get('Frequency', 8200.0) or 8200.0) + parameter_updates['Frequency'] = round(base_frequency * filter_factors.get(kind, 1.0), 3) + parameter_updates['Dry/Wet'] = {'intro': 0.18, 'build': 0.22, 'drop': 0.08, 'break': 0.28, 'outro': 0.14}.get(kind, 0.16) + elif 'utility' in device_name_lower: + parameter_updates['Stereo Width'] = width_targets.get(kind, 1.08) + elif send_key == 'echo': + if 'echo' in device_name_lower: + parameter_updates['Dry/Wet'] = echo_mix.get(kind, 0.78) + elif 'auto filter' in device_name_lower: + base_frequency = float(base_parameters.get('Frequency', 9800.0) or 9800.0) + parameter_updates['Frequency'] = round(base_frequency * {'intro': 0.94, 'build': 1.08, 'drop': 0.88, 'break': 0.9, 'outro': 0.92}.get(kind, 1.0), 3) + parameter_updates['Dry/Wet'] = {'intro': 0.08, 'build': 0.14, 'drop': 0.06, 'break': 0.16, 'outro': 0.09}.get(kind, 0.1) + elif 'hybrid reverb' in device_name_lower: + parameter_updates['Dry/Wet'] = {'intro': 0.12, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.1}.get(kind, 0.12) + elif send_key == 'heat': + if 'saturator' in device_name_lower: + base_drive = float(base_parameters.get('Drive', 4.5) or 4.5) + parameter_updates['Drive'] = round(max(0.5, base_drive + drive_offsets.get(kind, 0.0)), 3) + elif 'compressor' in device_name_lower: + base_threshold = float(base_parameters.get('Threshold', -16.0) or -16.0) + parameter_updates['Threshold'] = round(base_threshold + threshold_offsets.get(kind, 0.0), 3) + elif send_key == 'glue': + if 'compressor' in device_name_lower: + base_threshold = float(base_parameters.get('Threshold', -18.0) or -18.0) + parameter_updates['Threshold'] = round(base_threshold + {'intro': 1.0, 'build': -0.6, 'drop': -1.4, 'break': 1.8, 'outro': 0.8}.get(kind, 0.0), 3) + elif 'limiter' in device_name_lower: + parameter_updates['Gain'] = {'intro': -0.4, 'build': 0.0, 'drop': 0.35, 'break': -0.6, 'outro': -0.3}.get(kind, 0.0) + + for parameter_name, value in parameter_updates.items(): + state['device_parameters'].append({ + 'device_index': int(device_index), + 'device_name': device_name, + 'parameter': parameter_name, + 'value': value, + }) + + states.append(state) + + return states + +# ========================================================================= + # SECTION AUTOMATION METHODS + # ========================================================================= + + def _generate_automation_envelope( + self, + parameter_start: float, + parameter_end: float, + section_length: float, + curve_name: str = 'linear', + num_points: int = 8 + ) -> List[Dict[str, Any]]: + """ + Generate automation envelope points for a parameter over a section. + + Args: + parameter_start: Starting value of the parameter + parameter_end: Ending value of the parameter + section_length: Length of the section in beats + curve_name: Name of the envelope curve to use + num_points: Number of envelope points to generate + + Returns: + List of automation points with time and value + """ + curve_func = ENVELOPE_CURVES.get(curve_name, ENVELOPE_CURVES['linear']) + envelope_points = [] + + for i in range(num_points): + position = i / (num_points - 1) if num_points > 1 else 0.0 + curved_position = curve_func(position) + value = parameter_start + (parameter_end - parameter_start) * curved_position + time = section_length * position + + envelope_points.append({ + 'time': round(time, 3), + 'value': round(value, 4), + 'curve_position': round(position, 3), + }) + + return envelope_points + + def _build_section_automation( + self, + section: Dict[str, Any], + buses: List[Dict[str, Any]], + returns: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Build automation data for a single section. + + Args: + section: Section configuration dictionary + buses: List of bus track configurations + returns: List of return track configurations + + Returns: + Dictionary containing automation data for the section + """ + kind = str(section.get('kind', 'drop')).lower() + section_length = float(section.get('beats', 32.0)) + energy = float(section.get('energy', 1)) + + # Get base automation template for this section kind + base_automation = SECTION_AUTOMATION.get(kind, SECTION_AUTOMATION.get('drop', {})) + + # Determine envelope curve + curve_name = base_automation.get('envelope_curve', 'linear') + + # Apply energy scaling + energy_factor = max(0.5, min(1.5, energy / 3.0)) + + automation_data = { + 'section_index': int(section.get('index', 0)), + 'section_name': section.get('name', 'SECTION'), + 'section_kind': kind, + 'section_length': section_length, + 'energy': round(base_automation.get('energy', 0.5) * energy_factor, 3), + 'bus_automation': [], + 'return_automation': [], + 'master_automation': {}, + } + + # Build bus automation + for bus in buses: + bus_key = str(bus.get('key', '')).lower() + if not bus_key: + continue + + bus_filter_settings = base_automation.get('filters', {}).get(bus_key, {}) + if not bus_filter_settings: + continue + + bus_auto = { + 'bus_key': bus_key, + 'bus_name': bus.get('name', bus_key.upper()), + 'parameters': [] + } + + # Filter frequency automation + if 'frequency' in bus_filter_settings: + freq_start = bus_filter_settings['frequency'] * (1.1 - energy_factor * 0.2) + freq_end = bus_filter_settings['frequency'] * energy_factor + bus_auto['parameters'].append({ + 'device': 'Auto Filter', + 'parameter': 'Frequency', + 'envelope': self._generate_automation_envelope( + freq_start, freq_end, section_length, curve_name + ), + 'start_value': round(freq_start, 1), + 'end_value': round(freq_end, 1), + }) + + # Filter resonance automation + if 'resonance' in bus_filter_settings: + res_start = bus_filter_settings['resonance'] * 0.8 + res_end = bus_filter_settings['resonance'] * energy_factor + bus_auto['parameters'].append({ + 'device': 'Auto Filter', + 'parameter': 'Resonance', + 'envelope': self._generate_automation_envelope( + res_start, res_end, section_length, 'ease_in_out' + ), + 'start_value': round(res_start, 3), + 'end_value': round(res_end, 3), + }) + + if bus_auto['parameters']: + automation_data['bus_automation'].append(bus_auto) + + # Build return automation + reverb_settings = base_automation.get('reverb', {}) + delay_settings = base_automation.get('delay', {}) + compression_settings = base_automation.get('compression', {}) + saturation_settings = base_automation.get('saturation', {}) + stereo_width_settings = base_automation.get('stereo_width', {}) + + for return_track in returns: + send_key = str(return_track.get('send_key', '')).lower() + if not send_key: + continue + + return_auto = { + 'send_key': send_key, + 'return_name': return_track.get('name', send_key.upper()), + 'parameters': [] + } + + if send_key == 'space' and reverb_settings: + # Reverb send level + return_auto['parameters'].append({ + 'device': 'Hybrid Reverb', + 'parameter': 'Dry/Wet', + 'envelope': self._generate_automation_envelope( + reverb_settings.get('send_level', 0.2) * 0.9, + reverb_settings.get('send_level', 0.2) * energy_factor, + section_length, curve_name + ), + 'start_value': round(reverb_settings.get('send_level', 0.2) * 0.9, 3), + 'end_value': round(reverb_settings.get('send_level', 0.2) * energy_factor, 3), + }) + # Decay time + return_auto['parameters'].append({ + 'device': 'Hybrid Reverb', + 'parameter': 'Decay Time', + 'envelope': self._generate_automation_envelope( + reverb_settings.get('decay_time', 2.0) * 0.85, + reverb_settings.get('decay_time', 2.0), + section_length, 'ease_out' + ), + 'start_value': round(reverb_settings.get('decay_time', 2.0) * 0.85, 2), + 'end_value': round(reverb_settings.get('decay_time', 2.0), 2), + }) + + elif send_key == 'echo' and delay_settings: + # Delay send level + return_auto['parameters'].append({ + 'device': 'Echo', + 'parameter': 'Dry/Wet', + 'envelope': self._generate_automation_envelope( + delay_settings.get('send_level', 0.15) * 0.85, + delay_settings.get('send_level', 0.15) * energy_factor, + section_length, curve_name + ), + 'start_value': round(delay_settings.get('send_level', 0.15) * 0.85, 3), + 'end_value': round(delay_settings.get('send_level', 0.15) * energy_factor, 3), + }) + # Feedback + return_auto['parameters'].append({ + 'device': 'Echo', + 'parameter': 'Feedback', + 'envelope': self._generate_automation_envelope( + delay_settings.get('feedback', 0.3) * 0.8, + delay_settings.get('feedback', 0.3), + section_length, 'ramp_up' + ), + 'start_value': round(delay_settings.get('feedback', 0.3) * 0.8, 3), + 'end_value': round(delay_settings.get('feedback', 0.3), 3), + }) + + elif send_key == 'heat' and saturation_settings: + # Saturation drive + return_auto['parameters'].append({ + 'device': 'Saturator', + 'parameter': 'Drive', + 'envelope': self._generate_automation_envelope( + saturation_settings.get('drive', 2.0) * 0.6, + saturation_settings.get('drive', 2.0) * energy_factor, + section_length, 'ramp_up' + ), + 'start_value': round(saturation_settings.get('drive', 2.0) * 0.6, 2), + 'end_value': round(saturation_settings.get('drive', 2.0) * energy_factor, 2), + }) + + elif send_key == 'glue' and compression_settings: + # Compressor threshold + return_auto['parameters'].append({ + 'device': 'Compressor', + 'parameter': 'Threshold', + 'envelope': self._generate_automation_envelope( + compression_settings.get('threshold', -12.0) + 3, + compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, + section_length, 'ease_in' + ), + 'start_value': round(compression_settings.get('threshold', -12.0) + 3, 1), + 'end_value': round(compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, 1), + }) + + if return_auto['parameters']: + automation_data['return_automation'].append(return_auto) + + # Build master automation + automation_data['master_automation'] = { + 'stereo_width': { + 'parameter': 'Stereo Width', + 'envelope': self._generate_automation_envelope( + stereo_width_settings.get('value', 1.0) * 0.9, + stereo_width_settings.get('value', 1.0), + section_length, 'ease_in_out' + ), + 'start_value': round(stereo_width_settings.get('value', 1.0) * 0.9, 3), + 'end_value': round(stereo_width_settings.get('value', 1.0), 3), + }, + 'compression': { + 'parameter': 'Ratio', + 'envelope': self._generate_automation_envelope( + compression_settings.get('ratio', 2.0) * 0.8, + compression_settings.get('ratio', 2.0) * energy_factor, + section_length, 'ease_in' + ), + 'start_value': round(compression_settings.get('ratio', 2.0) * 0.8, 2), + 'end_value': round(compression_settings.get('ratio', 2.0) * energy_factor, 2), + }, + } + + return automation_data + + def _build_full_automation_blueprint( + self, + sections: List[Dict[str, Any]], + buses: List[Dict[str, Any]], + returns: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Build complete automation blueprint for all sections. + + Args: + sections: List of section configurations + buses: List of bus track configurations + returns: List of return track configurations + + Returns: + List of automation data dictionaries, one per section + """ + automation_blueprint = [] + + for section in sections: + section_automation = self._build_section_automation(section, buses, returns) + automation_blueprint.append(section_automation) + + return automation_blueprint + + def _build_master_state(self, section_kind: str) -> Dict[str, Any]: + """ + Build master chain state for a section. + + Returns a snapshot payload with flat device parameters for master chain. + """ + section = section_kind.lower() + device_parameters = [] + for device_name, parameter_map in MASTER_DEVICE_AUTOMATION.items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop', 0.0)) + clamp = MASTER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_parameters.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + + return { + 'section': section, + 'device_parameters': device_parameters, + } + + def _build_device_parameters_for_role(self, role: str, section_kind: str) -> List[Dict[str, Any]]: + """ + Build flat device parameter automation entries for a track role in a section. + """ + role_lower = role.lower().replace(' ', '_').replace('-', '_') + if role_lower not in SECTION_DEVICE_AUTOMATION: + return [] + section = section_kind.lower() + device_params = [] + for device_name, parameter_map in SECTION_DEVICE_AUTOMATION.get(role_lower, {}).items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop', 0.0)) + clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_params.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + return device_params + + def _build_bus_device_parameters(self, bus_key: str, section_kind: str) -> List[Dict[str, Any]]: + """ + Build flat device parameter automation entries for a bus track in a section. + Uses BUS_DEVICE_AUTOMATION constant for per-section values. + """ + bus_key_lower = bus_key.lower() + if bus_key_lower not in BUS_DEVICE_AUTOMATION: + return [] + section = section_kind.lower() + device_params = [] + for device_name, parameter_map in BUS_DEVICE_AUTOMATION.get(bus_key_lower, {}).items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop',0.0)) + clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_params.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + return device_params + + def _build_performance_snapshots(self, blueprint_tracks: List[Dict[str, Any]], + sections: List[Dict[str, Any]], + returns: Optional[List[Dict[str, Any]]] = None, + buses: Optional[List[Dict[str, Any]]] = None, + reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: + performance = [] + stereo_roles = {'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pad', 'pluck', 'arp', 'counter', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal'} + profile_pan_width = float(self._current_generation_profile.get('pan_width', 0.12)) + volume_factors = { + 'intro': 0.86, + 'build': 0.94, + 'drop': 1.02, + 'break': 0.78, + 'outro': 0.8, + } + + # Build energy profile lookup by section index for adaptive mixing + energy_by_index = {} + if reference_energy_profile: + for i, ep in enumerate(reference_energy_profile): + energy_by_index[i] = ep.get('energy_mean', 0.5) + else: + # Fallback: use section features if available + for i, section in enumerate(sections): + features = section.get('features', {}) + energy_by_index[i] = features.get('energy_mean', features.get('energy', 0.5)) + + space_send_factors = { + 'intro': 1.15, + 'build': 1.0, + 'drop': 0.82, + 'break': 1.35, + 'outro': 1.05, + } + echo_send_factors = { + 'intro': 1.08, + 'build': 1.18, + 'drop': 0.78, + 'break': 1.45, + 'outro': 0.95, + } + heat_send_factors = { + 'intro': 0.55, + 'build': 0.92, + 'drop': 1.18, + 'break': 0.42, + 'outro': 0.72, + } + glue_send_factors = { + 'intro': 0.72, + 'build': 0.96, + 'drop': 1.08, + 'break': 0.58, + 'outro': 0.78, + } + + for section_idx, section in enumerate(sections): + kind = str(section.get('kind', 'drop')).lower() + energy = max(1, int(section.get('energy', 1))) + + # Get energy_mean from reference profile for adaptive volume scaling + ref_energy_mean = energy_by_index.get(section_idx, 0.5) + + snapshot = { + 'scene_index': int(section.get('index', len(performance))), + 'name': section.get('name', "SECTION"), + 'track_states': [], + 'return_states': self._build_return_states(list(returns or []), section), + 'bus_states': [], + } + + for track_index, track_data in enumerate(blueprint_tracks): + role = track_data.get('role', '') + base_volume = float(track_data.get('volume', 0.72)) + base_pan = float(track_data.get('pan', 0.0)) + base_sends = dict(track_data.get('sends', {})) + intensity = self._role_intensity(role, section) + is_muted = role != 'sc_trigger' and intensity <= 0 + + if is_muted: + target_volume = round(base_volume * 0.08, 3) + else: + factor = volume_factors.get(kind, 1.0) + max(0.0, (energy - 3) * 0.03) + if role in ['kick', 'sub_bass', 'bass'] and kind == 'drop': + factor += 0.04 + if role in ['pad', 'atmos', 'drone'] and kind == 'break': + factor += 0.08 + if role in ['reverse_fx', 'riser', 'impact'] and kind in ['build', 'break']: + factor += 0.06 * float(self._current_generation_profile.get('fx_bias', 1.0)) + + # Apply energy-based volume scaling from reference profile + if ref_energy_mean < 0.3: + # Quiet sections (intro, quiet breaks) - reduce volume + energy_volume_factor = 0.85 + elif ref_energy_mean > 0.7: + # High energy sections (drops, peaks) - boost volume + energy_volume_factor = 1.08 + else: + energy_volume_factor = 1.0 + + target_volume = round(min(1.0, max(0.0, base_volume * factor * energy_volume_factor)), 3) + + target_pan = base_pan + pan_variant = str(section.get('pan_variant', 'narrow')).lower() + if role in stereo_roles: + if pan_variant == 'tilt_left': + direction = -1.0 + width = profile_pan_width + elif pan_variant == 'tilt_right': + direction = 1.0 + width = profile_pan_width + elif pan_variant == 'wide': + direction = -1.0 if track_index % 2 == 0 else 1.0 + width = profile_pan_width * 1.1 + else: + direction = -1.0 if track_index % 2 == 0 else 1.0 + width = profile_pan_width * 0.55 + + if kind == 'break': + width *= 1.18 + elif kind == 'drop': + width *= 0.92 + target_pan = self._clamp_pan(base_pan + (direction * width)) + + target_sends = {} + for send_name, send_value in base_sends.items(): + send_factor = 1.0 + if send_name == 'space': + send_factor = space_send_factors.get(kind, 1.0) + elif send_name == 'echo': + send_factor = echo_send_factors.get(kind, 1.0) + elif send_name == 'heat': + send_factor = heat_send_factors.get(kind, 1.0) + elif send_name == 'glue': + send_factor = glue_send_factors.get(kind, 1.0) + + if role in ['riser', 'impact'] and kind in ['build', 'break']: + send_factor += 0.18 + if role == 'vocal' and kind in ['build', 'drop']: + send_factor += 0.12 + if role in ['kick', 'sub_bass', 'bass'] and send_name in ['heat', 'glue'] and kind == 'drop': + send_factor += 0.1 + if is_muted: + send_factor *= 0.25 + + target_sends[send_name] = round(min(1.0, max(0.0, float(send_value) * send_factor)), 3) + + track_state = { + 'track_index': track_index, + 'role': role, + 'mute': is_muted, + 'volume': target_volume, + 'pan': target_pan, + 'sends': target_sends, + } + + # Add device_parameters to track state + device_params = self._build_device_parameters_for_role(role, kind) + if device_params: + track_state['device_parameters'] = device_params + + snapshot['track_states'].append(track_state) + + # Add bus states to snapshot + for bus_data in list(buses or []): + bus_key = str(bus_data.get('key', '')).lower() + if not bus_key: + continue + bus_device_params = self._build_bus_device_parameters(bus_key, kind) + if bus_device_params: + bus_state = { + 'bus_key': bus_key, + 'bus_name': bus_data.get('name', bus_key.upper()), + 'device_parameters': bus_device_params, + } + snapshot['bus_states'].append(bus_state) + + # Add master state to snapshot + master_state = self._build_master_state(kind) + if master_state.get('device_parameters'): + snapshot['master_state'] = master_state + + performance.append(snapshot) + + return performance + + def _build_mix_automation_summary(self, performance: List[Dict]) -> Dict[str, Any]: + """ + Build summary of automation in performance snapshots. + + Returns: + - track_snapshots_with_device_automation: count + - return_snapshots_with_device_automation: count + - bus_snapshots_with_device_automation: count + - master_snapshots_count: count + - track_roles_touched: list of roles with device automation + - bus_keys_touched: list of bus keys with device automation + - master_parameters_touched: list of master params automated + """ + track_count = 0 + return_count = 0 + bus_count = 0 + master_count = 0 + track_roles = set() + bus_keys = set() + master_params = set() + + for snapshot in performance: + # Check track states + for track_state in snapshot.get('track_states', []): + if 'device_parameters' in track_state and track_state['device_parameters']: + track_count += 1 + role = track_state.get('role', 'unknown') + track_roles.add(role) + + # Check return states + for return_state in snapshot.get('return_states', []): + if 'device_parameters' in return_state and return_state['device_parameters']: + return_count += 1 + + # Check bus states + for bus_state in snapshot.get('bus_states', []): + if 'device_parameters' in bus_state and bus_state['device_parameters']: + bus_count += 1 + bus_key = bus_state.get('bus_key', 'unknown') + bus_keys.add(bus_key) + + # Check master state + master_state = snapshot.get('master_state', {}) + if master_state.get('device_parameters'): + master_count += 1 + for item in master_state.get('device_parameters', []): + param_name = str(item.get('parameter', '') or '').strip() + if param_name: + master_params.add(param_name) + + return { + 'track_snapshots_with_device_automation': track_count, + 'return_snapshots_with_device_automation': return_count, + 'bus_snapshots_with_device_automation': bus_count, + 'master_snapshots_count': master_count, + 'track_roles_touched': sorted(list(track_roles)), + 'bus_keys_touched': sorted(list(bus_keys)), + 'master_parameters_touched': sorted(list(master_params)) + } + + def _verify_automation_safety(self, performance: List[Dict]) -> List[str]: + """ + Verify automation values are within safe ranges. + + Returns list of warnings if any values are outside safe ranges. + """ + warnings = [] + + for i, snapshot in enumerate(performance): + # Check master state + master_state = snapshot.get('master_state', {}) + for item in master_state.get('device_parameters', []): + device_name = str(item.get('device_name', 'unknown')) + param_name = str(item.get('parameter', '') or '').strip() + value = float(item.get('value', 0.0)) + clamp = MASTER_SAFETY_CLAMPS.get(param_name) + if clamp and (value < clamp['min'] or value > clamp['max']): + warnings.append(f"Snapshot {i}: {device_name}.{param_name}={value} outside safe range [{clamp['min']}, {clamp['max']}]") + + return warnings + + def _build_gain_staging_summary(self, config: Dict[str, Any]) -> Dict[str, Any]: + """ + Build gain staging summary for the generated config. + """ + warnings = [] + + # Check bus volumes for extreme values + bus_volumes = self._calibrated_bus_volumes or {} + for bus_name, vol in bus_volumes.items(): + if vol > 0.9: + warnings.append(f"Bus {bus_name} volume > 0.9: {vol:.3f}") + + # Check master limiter gain + master = config.get('master', {}) + master_limiter_gain = 0.0 + for device in master.get('device_chain', []): + if device.get('device') == 'Limiter': + master_limiter_gain = device.get('parameters', {}).get('Gain', 0.0) + if master_limiter_gain > 1.0: + warnings.append(f"Master limiter gain > 1.0: {master_limiter_gain:.3f}") + + # Check track volumes + for track in config.get('tracks', []): + vol = track.get('volume', 0.0) + role = track.get('role', 'unknown') + if vol > 0.9: + warnings.append(f"Track {role} volume > 0.9: {vol:.3f}") + + return { + 'master_profile_used': getattr(self, '_master_profile_used', 'default'), + 'style_adjustments_applied': getattr(self, '_style_adjustments_applied', []), + 'bus_volumes': bus_volumes, + 'track_volume_overrides_count': getattr(self, '_gain_calibration_overrides_count', 0), + 'peak_reductions_applied_count': getattr(self, '_peak_reductions_count', 0), + 'headroom_target_db': TARGET_HEADROOM_DB, + 'warnings': warnings, + } + + def generate_config(self, genre: str, style: str = "", bpm: float = 0, + key: str = "", structure: str = "standard") -> Dict[str, Any]: + """ + Genera una configuración completa de track + + Args: + genre: Género musical + style: Sub-estilo + bpm: BPM (0 = auto) + key: Tonalidad ("" = auto) + structure: Tipo de estructura + """ + genre = genre.lower().replace(' ', '-') + style = style.lower() if style else "" + variant_seed = random.SystemRandom().randint(1000, 999999) + random.seed(variant_seed) + + # Decay pattern variant memory to allow reuse + _decay_pattern_variant_memory() + + # Reset gain staging counters + self._gain_calibration_overrides_count = 0 + self._peak_reductions_count = 0 + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + self._master_profile_used = 'default' + + reference_resolution = self._resolve_reference_track_profile(genre, style, bpm, key, structure) + if reference_resolution: + genre = reference_resolution.get('genre', genre) or genre + style = reference_resolution.get('style', style) + bpm = float(reference_resolution.get('bpm', bpm or 0)) + key = reference_resolution.get('key', key) + structure = reference_resolution.get('structure', structure) + + # Obtener configuración del género + genre_config = GENRE_CONFIGS.get(genre, GENRE_CONFIGS['techno']) + + # Determinar BPM + if bpm <= 0: + bpm = genre_config['default_bpm'] + + # Determinar key + if not key: + key = random.choice(genre_config['keys']) + + # Determinar estilo si no se especificó + if not style: + style = random.choice(genre_config['styles']) + + # Parsear key + _root_note = key[:-1] if len(key) > 1 else key # noqa: F841 - parsed when needed per section + is_minor = 'm' in key.lower() + scale = 'minor' if is_minor else 'major' + profile = self._build_arrangement_profile(genre, style, variant_seed) + profile['style_text'] = f"{genre} {style}".strip().lower() + profile['reference_name'] = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + self._current_generation_profile = profile + sections = self._build_sections(structure, style, variant_seed, profile) + + # Crear configuración base + config = { + 'name': f"{genre.title()} {style.title()}", + 'bpm': bpm, + 'key': key, + 'scale': scale, + 'genre': genre, + 'style': style, + 'structure': structure, + 'variant_seed': variant_seed, + 'arrangement_profile': profile['name'], + 'reference_track': reference_resolution.get('reference') if reference_resolution else None, + 'reference_energy_profile': reference_resolution.get('reference_energy_profile') if reference_resolution else None, + 'auto_generate': True, + 'sections': sections, + 'buses': self._build_mix_bus_blueprint(profile, genre, style, reference_resolution), + 'returns': self._build_return_blueprint(profile, genre, style, reference_resolution), + 'master': self._build_master_blueprint(profile, genre, style, reference_resolution), + 'tracks': [], + } + + # Generar tracks según género + config['tracks'] = self._generate_tracks_for_genre(genre, style, key, scale, structure, sections, profile) + config['performance'] = self._build_performance_snapshots(config['tracks'], sections, config.get('returns', []), config.get('buses', [])) + config['mix_automation_summary'] = self._build_mix_automation_summary(config['performance']) + config['mix_automation_warnings'] = self._verify_automation_safety(config['performance']) + config['gain_staging_summary'] = self._build_gain_staging_summary(config) + config['automation'] = self._build_full_automation_blueprint(sections, config.get('buses', []), config.get('returns', [])) + config['transition_events'] = self._generate_transition_events(sections) + + # Apply density rules to prevent overcrowding + config['transition_events'] = self._apply_transition_density_rules(config['transition_events'], sections) + + # Materialize transition events into track blueprints + config['tracks'] = self._materialize_transition_events(config, config['tracks']) + + config['locators'] = self._build_locators(sections) + config['total_bars'] = sum(section['bars'] for section in sections) + config['total_beats'] = float(config['total_bars'] * 4) + + # Add section variants summary + config['section_variants'] = { + section.get('name', f'section_{i}'): { + 'kind': section.get('kind', 'unknown'), + 'drum_variant': section.get('drum_variant', 'straight'), + 'kick_variant': section.get('kick_variant', (section.get('drum_role_variants') or {}).get('kick', 'straight')), + 'clap_variant': section.get('clap_variant', (section.get('drum_role_variants') or {}).get('clap', 'straight')), + 'hat_closed_variant': section.get('hat_closed_variant', (section.get('drum_role_variants') or {}).get('hat_closed', 'straight')), + 'bass_variant': section.get('bass_variant', 'anchor'), + 'bass_bank_variant': section.get('bass_bank_variant', section.get('bass_variant', 'anchor')), + 'melodic_variant': section.get('melodic_variant', 'motif'), + 'melodic_bank_variant': section.get('melodic_bank_variant', section.get('melodic_variant', 'motif')), + 'transition_fill': section.get('transition_fill', 'none'), + } + for i, section in enumerate(sections) + } + + # Crear summary + config['summary'] = f""" +🎵 Track Generado: {config['name']} +♩ BPM: {bpm} +🎹 Key: {key} +🎨 Style: {style} +📊 Tracks: {len(config['tracks'])} +""" + if config.get('reference_track'): + config['summary'] += f"🔊 Reference: {config['reference_track'].get('name')}\n" + + return config + + def _build_locators(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + locators = [] + arrangement_time = 0.0 + for section in sections: + locators.append({ + 'scene_index': int(section.get('index', len(locators))), + 'name': section.get('name', 'SECTION'), + 'bars': int(section.get('bars', 8)), + 'color': int(section.get('color', 10)), + 'time_beats': round(arrangement_time, 3), + }) + arrangement_time += float(section.get('beats', 0.0) or 0.0) + return locators + + def _generate_tracks_for_genre(self, genre: str, style: str, key: str, + scale: str, structure: str, sections: List[Dict[str, Any]], + profile: Optional[Dict[str, Any]] = None) -> List[Dict]: + """Genera la configuración de tracks según el género""" + track_specs = [] + style_text = f"{genre} {style}".lower() + + track_specs.extend([ + ('SC TRIGGER', 'sc_trigger', TRACK_COLORS['technical'], 'operator'), + ('KICK', 'kick', TRACK_COLORS['kick'], 'operator'), + ('CLAP', 'clap', TRACK_COLORS['clap'], 'operator'), + ('SNARE FILL', 'snare_fill', TRACK_COLORS['snare'], 'operator'), + ('HAT CLOSED', 'hat_closed', TRACK_COLORS['hat'], 'operator'), + ('HAT OPEN', 'hat_open', TRACK_COLORS['hat'], 'operator'), + ('TOP LOOP', 'top_loop', TRACK_COLORS['hat'], 'operator'), + ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), + ('TOM FILL', 'tom_fill', TRACK_COLORS['perc'], 'operator'), + ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), + ('BASS', 'bass', TRACK_COLORS['bass'], 'operator'), + ('DRONE', 'drone', TRACK_COLORS['pad'], 'analog'), + ('CHORDS', 'chords', TRACK_COLORS['chords'], 'wavetable'), + ('STAB', 'stab', TRACK_COLORS['synth'], 'operator'), + ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), + ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), + ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), + ('COUNTER', 'counter', TRACK_COLORS['synth'], 'operator'), + ('CRASH', 'crash', TRACK_COLORS['fx'], 'operator'), + ('REVERSE FX', 'reverse_fx', TRACK_COLORS['fx'], 'analog'), + ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), + ('IMPACT FX', 'impact', TRACK_COLORS['fx'], 'operator'), + ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), + ]) + tracks = [] + + # Synths/Chords según género + if genre in ['house', 'trance', 'progressive']: + tracks.append(self._generate_chord_track(key, scale, genre)) + tracks.append(self._generate_lead_track(key, scale, genre)) + elif genre in ['techno', 'tech-house']: + if random.random() > 0.3: # 70% de probabilidad + tracks.append(self._generate_chord_track(key, scale, genre)) + if random.random() > 0.5: + tracks.append(self._generate_lead_track(key, scale, genre)) + + # FX/Atmósfera para estructuras extended + if structure in ['extended', 'club'] or random.random() > 0.6: + tracks.append(self._generate_fx_track()) + + if genre in ['techno', 'tech-house', 'trance']: + track_specs.insert(8, ('RIDE', 'ride', TRACK_COLORS['ride'], 'operator')) + if genre in ['house', 'tech-house', 'trance'] or 'latin' in style_text: + track_specs.insert(14, ('PLUCK', 'pluck', TRACK_COLORS['synth'], 'wavetable')) + track_specs.insert(15, ('VOCAL CHOP', 'vocal', TRACK_COLORS['vocal'], 'wavetable')) + elif genre == 'drum-and-bass': + track_specs = [ + ('BREAK', 'kick', TRACK_COLORS['kick'], 'operator'), + ('SNARE', 'clap', TRACK_COLORS['snare'], 'operator'), + ('HATS', 'hat_closed', TRACK_COLORS['hat'], 'operator'), + ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), + ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), + ('REESE', 'bass', TRACK_COLORS['bass'], 'operator'), + ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), + ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), + ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), + ('VOCAL', 'vocal', TRACK_COLORS['vocal'], 'wavetable'), + ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), + ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), + ] + + blueprint_tracks = [] + active_profile = dict(profile or self._current_generation_profile or {'name': 'default'}) + for name, role, color, device in track_specs: + clips = self._build_scene_clips(role, genre, style, key, scale, sections) + if not clips: + continue + + mix_profile = dict(ROLE_MIX.get(role, {})) + mix_profile['sends'] = self._extend_parallel_sends(role, mix_profile.get('sends', {})) + mix_profile = self._shape_mix_profile(role, mix_profile, active_profile, style) + track = { + 'name': name, + 'type': 'midi', + 'role': role, + 'bus': self._resolve_bus_for_role(role), + 'device': device, + 'color': color, + 'volume': mix_profile.get('volume', 0.72), + 'pan': mix_profile.get('pan', 0.0), + 'sends': dict(mix_profile.get('sends', {})), + 'fx_chain': self._shape_role_fx_chain(role, active_profile, style), + 'clips': clips, + } + track['clip'] = dict(clips[0]) + + # Agregar metadata de variación al blueprint + if role in SECTION_VARIATION_CONFIG: + track['section_variation'] = SECTION_VARIATION_CONFIG[role] + track['can_vary_by_section'] = True + + blueprint_tracks.append(track) + + return blueprint_tracks + + def _build_sections(self, structure: str, style: str = "", variant_seed: Optional[int] = None, + profile: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + structure_key = structure.lower() + rng = random.Random(variant_seed) if variant_seed is not None else random + blueprint_options = SECTION_BLUEPRINT_VARIANTS.get(structure_key) + if blueprint_options: + if 'latin' in style and structure_key == 'club' and len(blueprint_options) > 1: + blueprint = rng.choice(blueprint_options[1:]) + else: + blueprint = rng.choice(blueprint_options) + else: + blueprint = SECTION_BLUEPRINTS.get(structure_key, SECTION_BLUEPRINTS['standard']) + sections = [] + style_text = style.lower() if style else "" + profile_name = str((profile or {}).get('name', 'default')).lower() + for index, (name, bars, color, kind, energy) in enumerate(blueprint): + if kind == 'intro': + drum_variants = ['straight', 'skip'] + bass_variants = ['anchor', 'pedal'] + melodic_variants = ['motif', 'response'] + elif kind == 'build': + drum_variants = ['shuffle', 'pressure', 'straight'] + bass_variants = ['bounce', 'syncopated'] + melodic_variants = ['lift', 'response'] + elif kind == 'break': + drum_variants = ['skip', 'shuffle'] + bass_variants = ['pedal', 'anchor'] + melodic_variants = ['drone', 'response'] + elif kind == 'outro': + drum_variants = ['straight', 'skip'] + bass_variants = ['anchor', 'pedal'] + melodic_variants = ['motif', 'descend'] + else: + drum_variants = ['straight', 'pressure', 'shuffle'] + bass_variants = ['syncopated', 'bounce', 'anchor'] + melodic_variants = ['lift', 'motif', 'descend'] + + swing_pool = [0.0, 0.015, 0.025] + if 'latin' in style_text or profile_name in ['jackin', 'swing']: + swing_pool.extend([0.035, 0.045, 0.055]) + + pan_variant = rng.choice(['narrow', 'wide', 'tilt_left', 'tilt_right']) + if kind in ['intro', 'outro'] and rng.random() > 0.5: + pan_variant = 'narrow' + if kind == 'break' and rng.random() > 0.4: + pan_variant = 'wide' + + section_data = { + 'index': index, + 'name': name, + 'bars': int(bars), + 'beats': float(bars * 4), + 'color': color, + 'kind': kind, + 'energy': int(energy), + 'density': round(min(1.35, max(0.68, 0.78 + (energy * 0.08) + rng.uniform(-0.08, 0.14))), 3), + 'swing': round(rng.choice(swing_pool), 3), + 'tension': int(min(5, max(1, energy + rng.choice([-1, 0, 0, 1])))), + 'drum_variant': rng.choice(drum_variants), + 'bass_variant': rng.choice(bass_variants), + 'melodic_variant': rng.choice(melodic_variants), + 'pan_variant': pan_variant, + 'transition_fill': rng.choice(['none', 'snare', 'tom', 'reverse', 'impact']), + } + sections.append(self._ensure_section_pattern_variants(section_data)) + # Check for excessive repetition and force variation if needed + sections = self._check_section_repetition(sections) + return sections + + def _role_intensity(self, role: str, section: Dict[str, Any]) -> int: + kind = section.get('kind', 'drop') + energy = int(section.get('energy', 1)) + role_energy = ROLE_ACTIVITY.get(role, {}).get(kind, 0) + return min(max(role_energy, 0), max(1, energy + 1)) + + def _build_scene_clips(self, role: str, genre: str, style: str, key: str, + scale: str, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + clips = [] + for section in sections: + notes = self._render_scene_notes(role, genre, style, key, scale, section) + if not notes: + continue + + clips.append({ + 'scene_index': section['index'], + 'length': section['beats'], + 'name': f"{role.upper()} - {section['name']}", + 'notes': notes, + }) + return clips + + def _render_scene_notes(self, role: str, genre: str, style: str, key: str, + scale: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: + intensity = self._role_intensity(role, section) + if intensity <= 0: + return [] + + if role in ['sc_trigger', 'kick', 'clap', 'snare_fill', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'tom_fill', 'ride', 'crash']: + return self._render_drum_scene(role, genre, style, section, intensity) + if role in ['sub_bass', 'bass']: + return self._render_bass_scene(role, genre, style, key, section) + if role in ['chords', 'stab', 'pad', 'pluck', 'arp', 'lead', 'counter']: + return self._render_musical_scene(role, genre, key, scale, section) + if role in ['drone', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal']: + return self._render_fx_scene(role, key, section) + return [] + + def _render_drum_scene(self, role: str, genre: str, style: str, + section: Dict[str, Any], intensity: int) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + style_text = f"{genre} {style}".lower() + + if role == 'sc_trigger': + pattern = [self._make_note(24, beat, 0.12, 127) for beat in [0.0, 1.0, 2.0, 3.0]] + if kind == 'break': + pattern = [self._make_note(24, beat, 0.1, 118) for beat in [0.0, 2.0]] + return self._repeat_pattern(pattern, total_length, 4.0) + + if role == 'kick': + if genre == 'drum-and-bass': + pattern = [ + self._make_note(36, 0.0, 0.25, 122), + self._make_note(36, 0.75, 0.2, 104), + self._make_note(36, 1.5, 0.2, 112), + self._make_note(36, 2.0, 0.25, 124), + self._make_note(36, 2.75, 0.2, 100), + self._make_note(36, 3.25, 0.2, 92), + ] + elif kind == 'break': + pattern = [ + self._make_note(36, 0.0, 0.25, 118), + self._make_note(36, 2.0, 0.25, 110), + ] + else: + pattern = [self._make_note(36, beat, 0.25, 126 if beat == 0 else 118) for beat in [0.0, 1.0, 2.0, 3.0]] + if intensity >= 4 and genre in ['techno', 'tech-house']: + pattern.append(self._make_note(36, 3.5, 0.15, 94)) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop', 'outro']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'clap': + pitch = 38 if genre == 'drum-and-bass' else 39 + if kind == 'intro': + pattern = [self._make_note(pitch, 3.0, 0.2, 88)] + elif kind == 'break': + pattern = [self._make_note(pitch, 1.0, 0.2, 84)] + else: + pattern = [ + self._make_note(pitch, 1.0, 0.25, 108), + self._make_note(pitch, 3.0, 0.25, 108), + ] + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'snare_fill': + if kind not in ['build', 'break', 'drop']: + return [] + if str(section.get('transition_fill', 'snare')).lower() not in ['snare', 'impact'] and kind != 'drop': + return [] + fill_span = 2.0 if kind == 'build' and total_length >= 8.0 else 1.0 + fill_start = max(0.0, total_length - fill_span) + step = 0.25 if intensity <= 2 else 0.125 + velocity = 76 + notes = [] + current = fill_start + while current < total_length - 0.01: + notes.append(self._make_note(38, current, 0.08 if step < 0.2 else 0.12, min(124, velocity))) + current += step + velocity += 3 + if kind == 'drop': + notes.insert(0, self._make_note(38, 0.0, 0.15, 102)) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'hat_closed': + if intensity <= 1: + pattern = [self._make_note(42, beat, 0.1, 86) for beat in [0.5, 1.5, 2.5, 3.5]] + elif intensity == 2: + pattern = [self._make_note(42, step * 0.5, 0.1, 90 if step % 2 == 0 else 72) for step in range(8)] + else: + pattern = [self._make_note(42, step * 0.5, 0.1, 92 if step % 2 == 0 else 74) for step in range(8)] + pattern.extend([self._make_note(42, 1.75, 0.08, 64), self._make_note(42, 3.75, 0.08, 62)]) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop', 'outro']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'hat_open': + if kind in ['intro', 'break'] and intensity <= 1: + return [] + pattern = [self._make_note(46, 3.5, 0.35, 82)] + if intensity >= 3: + pattern.append(self._make_note(46, 1.5, 0.25, 74)) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'top_loop': + if kind in ['intro', 'break'] and intensity <= 1: + return [] + pattern = [ + self._make_note(44, 0.25, 0.08, 56), + self._make_note(44, 0.75, 0.08, 62), + self._make_note(44, 1.25, 0.08, 58), + self._make_note(44, 1.75, 0.08, 66), + self._make_note(44, 2.25, 0.08, 58), + self._make_note(44, 2.75, 0.08, 64), + self._make_note(44, 3.25, 0.08, 60), + self._make_note(44, 3.75, 0.08, 68), + ] + if 'latin' in style_text: + pattern.extend([ + self._make_note(54, 0.5, 0.08, 52), + self._make_note(54, 2.5, 0.08, 54), + ]) + if intensity >= 3: + pattern.extend([ + self._make_note(44, 1.125, 0.06, 48), + self._make_note(44, 3.125, 0.06, 50), + ]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'perc': + if kind in ['intro', 'outro'] and intensity <= 1: + return [] + pattern = [ + self._make_note(37, 0.75, 0.1, 62), + self._make_note(37, 1.25, 0.1, 58), + self._make_note(37, 2.75, 0.1, 64), + self._make_note(50, 3.25, 0.12, 70), + ] + if 'latin' in style_text: + pattern.extend([ + self._make_note(64, 1.75, 0.12, 68), + self._make_note(64, 2.125, 0.12, 64), + ]) + if intensity >= 3: + pattern.extend([self._make_note(37, 0.25, 0.1, 56), self._make_note(47, 2.25, 0.1, 68)]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'tom_fill': + if kind not in ['build', 'drop']: + return [] + if str(section.get('transition_fill', 'tom')).lower() not in ['tom', 'impact'] and kind != 'drop': + return [] + fill_start = max(0.0, total_length - 1.0) + sequence = [47, 50, 45, 47, 50] + velocities = [72, 76, 80, 88, 96] + notes = [] + for index, pitch in enumerate(sequence): + start = fill_start + (index * 0.2) + if start >= total_length: + break + notes.append(self._make_note(pitch, start, 0.18, velocities[index])) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'ride': + if kind not in ['build', 'drop', 'outro']: + return [] + pattern = [self._make_note(51, float(beat), 0.2, 82) for beat in range(4)] + if intensity >= 3: + pattern.extend([self._make_note(51, beat + 0.5, 0.15, 64) for beat in range(4)]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'crash': + if kind not in ['build', 'drop', 'break', 'outro']: + return [] + hit_positions = [0.0] + if kind == 'drop' and total_length >= 16.0: + hit_positions.append(8.0) + if kind == 'outro' and total_length >= 8.0: + hit_positions.append(total_length - 4.0) + notes = [ + self._make_note(49, position, min(1.5, max(0.25, total_length - position)), 82 if position == 0.0 else 70) + for position in hit_positions + if position < total_length + ] + return self._vary_drum_notes(notes, role, section, total_length) + + return [] + + def _bass_style_for_section(self, genre: str, style: str, role: str, section_kind: str) -> str: + style_text = f"{genre} {style}".lower() + if role == 'sub_bass': + return 'minimal' if section_kind != 'drop' else 'offbeat' + if 'acid' in style_text: + return 'acid' + if genre == 'house': + return 'offbeat' + if genre == 'drum-and-bass': + return 'rolling' + if section_kind in ['intro', 'outro', 'break']: + return 'minimal' + if genre == 'tech-house': + return 'offbeat' + return 'rolling' + + def _render_bass_scene(self, role: str, genre: str, style: str, key: str, + section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + scale_name = 'minor' if 'm' in key.lower() else 'major' + + if kind == 'break': + notes = self._build_pad_motion(key, scale_name, total_length, 2, 4.0) + else: + notes = self.create_bassline(key, self._bass_style_for_section(genre, style, role, kind), total_length) + + if role == 'sub_bass': + notes = self._transpose_notes(notes, -12) + notes = self._scale_note_lengths(notes, 1.35, minimum=0.2) + notes = self._vary_bass_notes(notes, role, key, section, total_length) + if kind in ['build', 'drop'] and total_length >= 8.0: + turnaround = self._build_turnaround_notes(key, scale_name, total_length, 2 if role == 'bass' else 1, 88 if role == 'bass' else 80) + notes = self._merge_section_notes(notes, turnaround, total_length) + return notes + + def _render_musical_scene(self, role: str, genre: str, key: str, scale: str, + section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + + if role == 'pad': + notes = self._build_pad_motion(key, scale, total_length, 4, 8.0 if kind == 'break' else 4.0) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + if role == 'chords': + progression_type = 'techno' if genre in ['techno', 'tech-house'] else ('trance' if genre == 'trance' else 'house') + notes = self.create_chord_progression(key, progression_type, total_length) + notes = self._scale_note_lengths(notes, 1.15, minimum=0.25) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + if role == 'stab': + notes = self.create_chord_progression(key, 'techno' if genre in ['techno', 'tech-house'] else 'house', total_length) + notes = self._scale_note_lengths(notes, 0.4, minimum=0.1) + shifted = [] + for note in notes: + start = float(note['start']) + (0.5 if int(float(note['start'])) % 2 == 0 else 0.0) + shifted.append(self._make_note(note['pitch'], start, note['duration'], min(118, note['velocity'] + 6))) + return self._vary_melodic_notes(shifted, role, key, scale, section, total_length) + + if role == 'pluck': + notes = self.create_melody(key, scale, total_length, genre) + notes = self._scale_note_lengths(notes, 0.55, minimum=0.12) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + notes = self.create_melody(key, scale, total_length, genre) + if role == 'arp': + notes = self._scale_note_lengths(notes, 0.45, minimum=0.1) + elif role == 'lead': + notes = self._transpose_notes(notes, 12) + elif role == 'counter': + sparse = [] + for note in notes: + start = float(note['start']) + if (start % 4.0) < 2.0: + continue + sparse.append(self._make_note(note['pitch'] - 12, start, max(0.2, float(note['duration']) * 0.8), max(50, int(note['velocity']) - 10))) + notes = sparse + notes = self._vary_melodic_notes(notes, role, key, scale, section, total_length) + if role in ['lead', 'arp', 'pluck', 'counter'] and kind in ['build', 'drop'] and total_length >= 8.0: + notes = self._merge_section_notes(notes, self._build_turnaround_notes(key, scale, total_length, 5, 84), total_length) + return notes + + def _render_fx_scene(self, role: str, key: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section.get('kind', 'drop') + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + rng = self._section_rng(section, role, salt=19) + + if role == 'drone': + notes = [ + self._make_note(root_midi - 12, 0.0, min(total_length, 8.0 if kind == 'break' else total_length), 58), + self._make_note(root_midi - 5, max(0.0, total_length / 2.0), min(total_length / 2.0, 8.0), 52), + ] + if kind in ['build', 'drop'] and total_length >= 12.0: + notes.append(self._make_note(root_midi + 2, max(0.0, total_length - 6.0), 4.0, 48)) + return notes + + if role == 'reverse_fx': + if str(section.get('transition_fill', 'reverse')).lower() not in ['reverse', 'impact'] and kind not in ['break', 'build']: + return [] + notes = [] + for span, offset, velocity in ((4.0, 4.0, 70), (2.0, 2.0, 64), (1.0, 1.0, 58)): + if total_length >= offset: + start = max(0.0, total_length - offset) + notes.append(self._make_note(root_midi + 12, start, min(span, total_length - start), velocity)) + if kind == 'build' and total_length >= 16.0 and rng.random() > 0.35: + notes.append(self._make_note(root_midi + 7, max(0.0, total_length - 8.0), 1.5, 56)) + return notes + + if role == 'riser': + notes = [] + sweep_start = max(0.0, total_length - min(8.0, total_length)) + for offset, pitch, velocity in ((0.0, root_midi + 7, 64), (2.0, root_midi + 12, 70), (4.0, root_midi + 19, 74), (6.0, root_midi + 24, 78)): + start = sweep_start + offset + if start < total_length: + notes.append(self._make_note(pitch, start, min(2.0, total_length - start), velocity)) + if kind == 'build' and total_length >= 8.0: + notes.extend([ + self._make_note(root_midi + 12, max(0.0, total_length - 2.0), 0.5, 82), + self._make_note(root_midi + 19, max(0.0, total_length - 1.0), 0.45, 86), + ]) + return notes + + if role == 'impact': + if kind in ['intro', 'outro'] and str(section.get('transition_fill', 'impact')).lower() != 'impact': + return [] + notes = [self._make_note(root_midi + 7, 0.0, 0.5, 82)] + if total_length >= 8.0 and kind in ['build', 'drop']: + notes.append(self._make_note(root_midi + 12, total_length - 0.5, 0.45, 76)) + if kind == 'drop' and total_length >= 16.0 and rng.random() > 0.4: + notes.append(self._make_note(root_midi + 10, 8.0, 0.35, 72)) + return notes + + if role == 'atmos': + notes = [ + self._make_note(root_midi, 0.0, min(8.0, total_length), 54), + self._make_note(root_midi + 7, max(0.0, total_length / 2.0), min(8.0, total_length / 2.0), 50), + ] + if kind in ['intro', 'break', 'outro'] and total_length >= 12.0: + notes.append(self._make_note(root_midi + 12, max(0.0, total_length - 4.0), min(4.0, total_length), 46)) + return notes + + if role == 'vocal': + notes = [] + if kind == 'intro': + base_positions = [7.5, 15.5] + elif kind == 'build': + base_positions = [1.5, 3.5, 5.5, 7.5] + if total_length >= 16.0: + base_positions.extend([11.5, 13.5, 15.5]) + elif kind == 'drop': + base_positions = [1.5, 2.75, 5.5, 6.75] + if total_length >= 16.0: + base_positions.extend([9.5, 10.75, 13.5, 14.75]) + elif kind == 'break': + base_positions = [3.5, 11.5] + else: + base_positions = [1.5, 5.5] + + for index, pos in enumerate(base_positions): + if pos >= total_length: + continue + pitch = root_midi + (10 if kind == 'drop' and index % 2 else 3) + duration = 0.22 if kind == 'drop' else 0.3 + velocity = 80 if kind in ['build', 'drop'] else 72 + if rng.random() > 0.82: + pitch += 12 + notes.append(self._make_note(pitch, pos, duration, velocity)) + + if kind == 'build' and total_length >= 8.0: + notes.append(self._make_note(root_midi + 15, max(0.0, total_length - 0.75), 0.22, 84)) + return notes + + return [] + + def _build_pad_motion(self, key: str, scale_name: str, total_length: float, + octave: int = 4, sustain_beats: float = 4.0) -> List[Dict[str, Any]]: + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, octave) + scale_notes = self.get_scale_notes(root_midi, scale_name) + progression = random.choice(CHORD_PROGRESSIONS.get('techno' if 'm' in key.lower() else 'house', CHORD_PROGRESSIONS['techno'])) + notes = [] + bars = max(1, int(total_length / 4.0)) + + for bar in range(bars): + degree = progression[bar % len(progression)] - 1 + chord_root = scale_notes[degree % len(scale_notes)] + start = float(bar * 4.0) + duration = min(sustain_beats, total_length - start) + for interval in [0, 7, 12]: + notes.append(self._make_note(chord_root + interval, start, duration, 66)) + return notes + + def _generate_drum_tracks(self, genre: str, style: str) -> List[Dict]: + """Genera tracks de batería""" + tracks = [] + + # Kick siempre + tracks.append({ + 'name': 'Kick', + 'type': 'midi', + 'color': TRACK_COLORS['kick'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_kick_pattern(genre, style) + } + }) + + # Snare/Clap + tracks.append({ + 'name': 'Clap', + 'type': 'midi', + 'color': TRACK_COLORS['clap'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_clap_pattern(genre, style) + } + }) + + # Hi-hats + tracks.append({ + 'name': 'HiHat', + 'type': 'midi', + 'color': TRACK_COLORS['hat'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_hat_pattern(genre, style) + } + }) + + # Percusión extra para estilos más complejos + if style in ['latin', 'afro', 'groovy', 'complex']: + tracks.append({ + 'name': 'Percussion', + 'type': 'midi', + 'color': TRACK_COLORS['hat'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_perc_pattern(genre, style) + } + }) + + return tracks + + def _generate_bass_track(self, key: str, scale: str, genre: str, style: str) -> Dict: + """Genera un track de bajo""" + notes = self.create_bassline(key, style, 16.0) + + return { + 'name': 'Bass', + 'type': 'midi', + 'color': TRACK_COLORS['bass'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_chord_track(self, key: str, scale: str, genre: str) -> Dict: + """Genera un track de acordes""" + notes = self.create_chord_progression(key, genre, 16.0) + + return { + 'name': 'Chords', + 'type': 'midi', + 'color': TRACK_COLORS['chords'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_lead_track(self, key: str, scale: str, genre: str) -> Dict: + """Genera un track lead/melódico""" + notes = self.create_melody(key, scale, 16.0, genre) + + return { + 'name': 'Lead', + 'type': 'midi', + 'color': TRACK_COLORS['synth'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_fx_track(self) -> Dict: + """Genera un track de FX/Atmósfera""" + return { + 'name': 'FX', + 'type': 'midi', + 'color': TRACK_COLORS['fx'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': self._create_fx_notes() + } + } + + # ========================================================================= + # PATRONES DE BATERÍA + # ========================================================================= + + def _create_kick_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de kick""" + notes = [] + + if style == 'minimal': + # Kick en 1 y 2.5 + for bar in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 120}) + notes.append({'pitch': 36, 'start': bar * 4.0 + 2.5, 'duration': 0.25, 'velocity': 110}) + elif style == 'four-on-the-floor' or genre in ['house', 'tech-house']: + # 4/4 clásico + for bar in range(4): + for beat in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) + else: # Default techno + for bar in range(4): + for beat in range(4): + vel = 127 if beat == 0 else 115 + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': vel}) + + return notes + + def _create_clap_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de clap/snare""" + notes = [] + + # Claps en 2 y 4 (beats 1 y 3 en 0-indexed) + for bar in range(4): + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.25, 'velocity': 110}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.25, 'velocity': 110}) + + # Snare adicional para DnB/Jungle + if genre == 'drum-and-bass': + for bar in range(4): + notes.append({'pitch': 38, 'start': bar * 4.0 + 1.75, 'duration': 0.1, 'velocity': 90}) + notes.append({'pitch': 38, 'start': bar * 4.0 + 2.25, 'duration': 0.1, 'velocity': 85}) + + return notes + + def _create_hat_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de hi-hats""" + notes = [] + + if style in ['minimal', 'dub']: + # Off-bats simples + for bar in range(4): + for beat in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) + else: + # 8vos con variación + for bar in range(4): + for beat in range(4): + for sub in range(2): + time = bar * 4.0 + beat + sub * 0.5 + vel = 90 if sub == 0 else 70 + notes.append({'pitch': 42, 'start': time, 'duration': 0.1, 'velocity': vel}) + + # Open hats ocasionales + if style not in ['minimal']: + for bar in range(4): + notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.5, 'velocity': 80}) + + return notes + + def _create_perc_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de percusión extra""" + notes = [] + + for bar in range(4): + # Shakers/congas en 16vos + for i in range(16): + time = bar * 4.0 + i * 0.25 + if i % 4 != 0: # Skip downbeats + vel = 60 + random.randint(-10, 10) + notes.append({'pitch': 37, 'start': time, 'duration': 0.1, 'velocity': vel}) + + return notes + + def _create_fx_notes(self) -> List[Dict]: + """Crea notas para FX/atmósfera""" + notes = [] + + # Swells y risers + for bar in [0, 2]: + # Nota larga ascendente + notes.append({'pitch': 84, 'start': bar * 4.0 + 3.0, 'duration': 1.0, 'velocity': 70}) + + return notes + + # ========================================================================= + # CREACIÓN DE PATRONES PARA MCP + # ========================================================================= + + def create_drum_pattern(self, style: str, pattern_type: str, length: float) -> List[Dict]: + """Crea un patrón de batería completo para usar con MCP""" + notes = [] + bars = int(length / 4.0) + + if pattern_type == 'kick-only': + for bar in range(bars): + for beat in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) + + elif pattern_type == 'hats-only': + for bar in range(bars): + for beat in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) + + elif pattern_type == 'minimal': + for bar in range(bars): + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 127}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 110}) + notes.append({'pitch': 42, 'start': bar * 4.0 + 2.5, 'duration': 0.1, 'velocity': 80}) + + else: # full + notes.extend(self._create_kick_pattern(style, 'standard')) + notes.extend(self._create_clap_pattern(style, 'standard')) + notes.extend(self._create_hat_pattern(style, 'standard')) + + return notes + + def create_bassline(self, key: str, style: str, length: float) -> List[Dict]: + """Crea una línea de bajo musical""" + notes = [] + + # Parsear key + root_note = key[:-1] if len(key) > 1 else key + is_minor = 'm' in key.lower() + scale_name = 'minor' if is_minor else 'major' + + root_midi = self.note_name_to_midi(root_note, 2) # Octava 2 para bajo + scale_notes = self.get_scale_notes(root_midi, scale_name) + + bars = int(length / 4.0) + + if style == 'rolling': + # Bass en 16vos + for bar in range(bars): + for beat in range(4): + for sub in range(4): + time = bar * 4.0 + beat + sub * 0.25 + if sub == 0: + pitch = root_midi + vel = 120 + elif sub == 2: + pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 + vel = 100 + else: + pitch = root_midi + vel = 80 if sub % 2 == 0 else 70 + + notes.append({'pitch': pitch, 'start': time, 'duration': 0.2, 'velocity': vel}) + + elif style == 'minimal': + # Solo en beats 1 y 3 + for bar in range(bars): + for beat in [0, 2]: + time = bar * 4.0 + beat + notes.append({'pitch': root_midi, 'start': time, 'duration': 1.5, 'velocity': 110}) + + elif style == 'offbeat': + # Notas en off-beats (house típico) + for bar in range(bars): + for beat in range(4): + time = bar * 4.0 + beat + 0.5 + pitch = root_midi if beat % 2 == 0 else scale_notes[3] + notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': 100}) + + elif style == 'acid': + # Estilo TB-303 con slides + for bar in range(bars): + for i in range(8): + time = bar * 4.0 + i * 0.5 + pitch = root_midi + random.choice([0, 3, 5, 7, 10]) + vel = 90 + random.randint(-20, 20) + notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': min(127, max(60, vel))}) + + else: # walking + for bar in range(bars): + for beat in range(4): + time = bar * 4.0 + beat + if beat == 0: + pitch = root_midi + elif beat == 1: + pitch = scale_notes[2] if len(scale_notes) > 2 else root_midi + 3 + elif beat == 2: + pitch = scale_notes[3] if len(scale_notes) > 3 else root_midi + 5 + else: + pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 + + notes.append({'pitch': pitch, 'start': time, 'duration': 0.9, 'velocity': 100}) + + return notes + + def create_chord_progression(self, key: str, progression_type: str, length: float) -> List[Dict]: + """Crea una progresión de acordes""" + notes = [] + + # Parsear key + root_note = key[:-1] if len(key) > 1 else key + is_minor = 'm' in key.lower() + scale_name = 'minor' if is_minor else 'major' + + root_midi = self.note_name_to_midi(root_note, 4) # Octava 4 para acordes + scale_notes = self.get_scale_notes(root_midi, scale_name) + + # Seleccionar progresión + progressions = CHORD_PROGRESSIONS.get(progression_type, CHORD_PROGRESSIONS['techno']) + progression = random.choice(progressions) + + bars = int(length / 4.0) + beats_per_bar = 4 + + for bar in range(bars): + degree = progression[bar % len(progression)] - 1 + + if degree < len(scale_notes): + chord_root = scale_notes[degree] + else: + chord_root = root_midi + + # Construir acorde (triada) + third = 3 if 'minor' in scale_name else 4 + chord_tones = [chord_root, chord_root + third, chord_root + 7] + + # Stab chords - cortos y percusivos + if progression_type == 'techno': + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar, + 'duration': 0.25, + 'velocity': 90 + }) + elif progression_type == 'house': + for beat in [0.5, 2.5]: + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar + beat, + 'duration': 0.5, + 'velocity': 75 + }) + else: + # Default: acordes en beats 1 y 3 + for beat in [0, 2]: + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar + beat, + 'duration': 1.0, + 'velocity': 85 + }) + + return notes + + def create_melody(self, key: str, scale: str, length: float, genre: str) -> List[Dict]: + """Crea una melodía/lead""" + notes = [] + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) # Octava 5 para lead + scale_notes = self.get_scale_notes(root_midi, scale) + + bars = max(1, int(length / 4.0)) + motif_pool = [ + ([0, 2, 4, 2, 5, 4], [0.0, 0.5, 1.5, 2.0, 2.75, 3.25]), + ([0, 3, 4, 6, 4], [0.0, 0.75, 1.5, 2.5, 3.25]), + ([0, 2, 3, 5, 3, 2], [0.0, 0.5, 1.0, 2.0, 2.5, 3.5]), + ] + motif_steps, motif_times = random.choice(motif_pool) + + for bar in range(bars): + bar_offset = bar * 4.0 + phrase_shift = 0 if bar % 4 in [0, 1] else random.choice([0, 1, -1, 2]) + invert_tail = (bar % 4 == 3) + for index, step in enumerate(motif_steps): + start = bar_offset + motif_times[index % len(motif_times)] + if start >= length: + continue + if invert_tail and index >= max(1, len(motif_steps) - 2): + start += 0.25 + if random.random() < 0.18 and index not in [0, len(motif_steps) - 1]: + continue + + scale_index = (step + phrase_shift) % len(scale_notes) + pitch = scale_notes[scale_index] + if genre in ['trance', 'progressive'] and index == len(motif_steps) - 1: + pitch += 12 + elif genre in ['techno', 'tech-house'] and index % 3 == 2: + pitch -= 12 + + duration = 0.22 if start % 1.0 not in [0.0, 0.5] else 0.35 + velocity = 78 + ((index + bar) % 3) * 8 + random.randint(-6, 8) + notes.append({ + 'pitch': pitch, + 'start': start, + 'duration': duration, + 'velocity': max(60, min(123, velocity)) + }) + + return notes diff --git a/AbletonMCP_AI/MCP_Server/start_server.py b/AbletonMCP_AI/MCP_Server/start_server.py new file mode 100644 index 0000000..1d3ca37 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/start_server.py @@ -0,0 +1,16 @@ +"""Wrapper to start MCP server with correct environment""" +import sys +import os + +# Force correct working directory +os.chdir(r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server') + +# Set up Python path for imports +sys.path.insert(0, r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server') +sys.path.insert(0, r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI') + +# Now import and run server +import importlib.util +spec = importlib.util.spec_from_file_location("server", "server.py") +server = importlib.util.module_from_spec(spec) +spec.loader.exec_module(server) diff --git a/AbletonMCP_AI/MCP_Server/template_analyzer.py b/AbletonMCP_AI/MCP_Server/template_analyzer.py new file mode 100644 index 0000000..b1823d2 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/template_analyzer.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import argparse +import gzip +import json +from collections import Counter +from pathlib import Path +import xml.etree.ElementTree as ET + + +def _node_name(node: ET.Element | None) -> str: + if node is None: + return "" + for tag in ("EffectiveName", "UserName", "Name"): + child = node.find(tag) + if child is not None: + value = child.attrib.get("Value", "") + if value: + return value + return node.attrib.get("Value", "") + + +def _device_name(device: ET.Element) -> str: + if device.tag == "PluginDevice": + info = device.find("PluginDesc/VstPluginInfo") + if info is None: + info = device.find("PluginDesc/AuPluginInfo") + if info is not None: + plug = info.find("PlugName") + if plug is not None and plug.attrib.get("Value"): + return plug.attrib["Value"] + return device.tag + + +def _session_clip_count(track: ET.Element) -> int: + count = 0 + for slot in track.findall("./DeviceChain/MainSequencer/ClipSlotList/ClipSlot"): + if slot.find("Value/MidiClip") is not None or slot.find("Value/AudioClip") is not None: + count += 1 + return count + + +def _arrangement_clip_count(track: ET.Element) -> int: + return len(track.findall(".//MainSequencer//MidiClip")) + len( + track.findall(".//MainSequencer//AudioClip") + ) + + +def _tempo_value(live_set: ET.Element) -> float | None: + node = live_set.find(".//Tempo/Manual") + if node is None: + return None + try: + return float(node.attrib.get("Value", "0")) + except ValueError: + return None + + +def _locator_summary(live_set: ET.Element) -> list[dict[str, float | str | None]]: + locators: list[tuple[float, str]] = [] + for locator in live_set.findall(".//Locators/Locators/Locator"): + try: + time = float(locator.find("Time").attrib.get("Value", "0")) + except (AttributeError, ValueError): + time = 0.0 + name = _node_name(locator.find("Name")) + locators.append((time, name)) + locators.sort(key=lambda item: item[0]) + summary: list[dict[str, float | str | None]] = [] + for index, (time, name) in enumerate(locators): + next_time = locators[index + 1][0] if index + 1 < len(locators) else None + summary.append( + { + "time_beats": time, + "name": name, + "section_length_beats": None if next_time is None else next_time - time, + } + ) + return summary + + +def _arrangement_length_beats(root: ET.Element) -> float: + max_end = 0.0 + for clip in root.findall(".//MidiClip") + root.findall(".//AudioClip"): + current_end = clip.find("CurrentEnd") + start = clip.attrib.get("Time") + if current_end is None or start is None: + continue + try: + end = float(start) + float(current_end.attrib.get("Value", "0")) + except ValueError: + continue + max_end = max(max_end, end) + return max_end + + +def analyze_set(als_path: Path) -> dict: + with gzip.open(als_path, "rb") as handle: + root = ET.parse(handle).getroot() + live_set = root.find("LiveSet") + if live_set is None: + raise ValueError(f"Invalid ALS file: {als_path}") + + tracks = list(live_set.find("Tracks") or []) + track_summaries = [] + device_counter: Counter[str] = Counter() + + for track in tracks: + devices = track.findall("./DeviceChain/DeviceChain/Devices/*") + device_names = [_device_name(device) for device in devices] + device_counter.update(device_names) + track_summaries.append( + { + "type": track.tag, + "name": _node_name(track.find("Name")), + "group_id": track.find("TrackGroupId").attrib.get("Value", "") + if track.find("TrackGroupId") is not None + else "", + "session_clip_count": _session_clip_count(track), + "arrangement_clip_count": _arrangement_clip_count(track), + "devices": device_names, + } + ) + + automation_events = 0 + for automation in root.findall(".//ArrangerAutomation"): + automation_events += len(automation.findall(".//FloatEvent")) + automation_events += len(automation.findall(".//EnumEvent")) + automation_events += len(automation.findall(".//BoolEvent")) + + return { + "file": str(als_path), + "tempo": _tempo_value(live_set), + "track_type_counts": dict(Counter(track.tag for track in tracks)), + "scene_count": len(live_set.findall("./SceneNames/Scene")), + "locators": _locator_summary(live_set), + "arrangement_length_beats": _arrangement_length_beats(root), + "automation_event_count": automation_events, + "top_devices": dict(device_counter.most_common(16)), + "tracks": track_summaries, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Analyze Ableton .als templates.") + parser.add_argument("path", nargs="?", default=".", help="Folder containing .als files") + parser.add_argument("--json", action="store_true", help="Emit JSON") + args = parser.parse_args() + + base = Path(args.path).resolve() + results = [analyze_set(path) for path in sorted(base.rglob("*.als"))] + + if args.json: + print(json.dumps(results, indent=2)) + return + + for result in results: + print(f"=== {Path(result['file']).name} ===") + print(f"tempo: {result['tempo']}") + print(f"tracks: {result['track_type_counts']}") + print(f"scenes: {result['scene_count']}") + print(f"arrangement_length_beats: {result['arrangement_length_beats']}") + print(f"automation_event_count: {result['automation_event_count']}") + print("locators:") + for locator in result["locators"]: + print( + f" - {locator['time_beats']:>6} {locator['name']}" + f" len={locator['section_length_beats']}" + ) + print("top_devices:") + for name, count in result["top_devices"].items(): + print(f" - {name}: {count}") + print() + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI/MCP_Server/tofix.md b/AbletonMCP_AI/MCP_Server/tofix.md new file mode 100644 index 0000000..0e8fece --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/tofix.md @@ -0,0 +1,82 @@ +# 🛠️ TOFIX — Pendientes del MCP AbletonMCP_AI + +> Última revisión: 2026-03-22 + +--- + +## 🔴 Crítico (bloquean funcionalidad) + +_(Ninguno actualmente — todos los errores de runtime F821/F841 han sido corregidos)_ + +--- + +## 🟠 Alta Prioridad (lint / calidad de código) + +### Archivos con permisos bloqueados por Windows ACL +Estos archivos tienen permisos de escritura restringidos por la instalación de Ableton. +Para editarlos necesitás **abrir el editor / terminal como Administrador**. + +| Archivo | Línea | Error | Descripción | +|---|---|---|---| +| `audio_analyzer.py` | 317 | F401 | `struct` importado pero nunca usado | +| `role_matcher.py` | 12 | F401 | `random` importado pero nunca usado (se importa inline donde se necesita) | +| `role_matcher.py` | 13 | F401 | `typing.Set` importado pero nunca usado | +| `sample_manager.py` | 13 | F401 | `os` importado pero nunca usado (reemplazado por `pathlib`) | +| `sample_manager.py` | 17 | F401 | `shutil` importado pero nunca usado | +| `sample_manager.py` | 19 | F401 | `typing.Set` importado pero nunca usado | +| `sample_manager.py` | 24 | F401 | `time` importado pero nunca usado | +| `sample_manager.py` | 28/32 | F401 | `audio_analyzer.quick_analyze` importado pero nunca llamado | +| `sample_manager.py` | 292 | F841 | `file_hash` asignado pero nunca usado | + +**Cómo fixear:** +```powershell +# Desde PowerShell como Administrador: +icacls "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\audio_analyzer.py" /grant Users:F +icacls "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\role_matcher.py" /grant Users:F +icacls "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\sample_manager.py" /grant Users:F +``` + +--- + +## 🟡 Media Prioridad (errores de análisis estático Pyre2) + +> Estos **NO son errores reales en Python** — son limitaciones del motor de análisis Pyre2 con código dinámico. No causan ningún problema en runtime. + +| Tipo | Patrón | Cantidad estimada | Causa real | +|---|---|---|---| +| `+=` no soportado | `defaultdict` + `int` | ~40+ | Pyre2 no infiere `defaultdict` correctamente | +| `*` no soportado | `dict[str, float] * float` | ~10+ | Pyre2 confunde el tipo de retorno de `.get()` | +| `in` no soportado | `str in set()` | ~5+ | Pyre2 pierde el tipo de `set` después de asignación | +| `round()` overload | `round(x, 3)` | ~6 | Bug conocido de Pyre2 con `ndigits != None` | +| `Cannot index` | `dict[Literal[...]]` | ~4 | Pyre2 infiere dict demasiado estricto | + +**Impacto real:** Ninguno. Todos son falsos positivos de inferencia de tipos. + +--- + +## 🟢 Baja Prioridad (mejoras arquitecturales) + +| Área | Descripción | +|---|---| +| `sample_manager.py` | `file_hash` se calcula pero no se usa para detectar cambios reales — actualmente usa `st_mtime`. Podría usarse para comparación más robusta. | +| `reference_listener.py` | `_compute_segment_features` referenciado pero el método no está visible en el scope de Pyre2 — verificar que está en la misma clase. | +| `reference_listener.py` | `str[::step]` slice con step — Pyre2 reporta error pero es Python válido. Documentar o usar `cast()`. | +| `song_generator.py` | Variables `materialized_track_roles` y `event_track_roles` son `set` pero nunca se leen después de ser llenadas — revisar si son necesarias. | +| `sample_manager.py` | `SampleType = None` como fallback cuando `audio_analyzer` no se puede importar — podría causar `TypeError` si se usa como clase. | + +--- + +## ✅ Ya corregido en esta sesión + +| Archivo | Fix | +|---|---| +| `song_generator.py:2691` | `kind` → `_kind` (F841) | +| `song_generator.py:4144` | `root_note` → `_root_note` (F841) | +| `song_generator.py:3265` | `Set[str]` → `set` (F821 — `Set` no importado) | +| `song_generator.py:3292` | `Set[str]` → `set` (F821 — `Set` no importado) | +| `reference_listener.py:243` | `falling` → `_falling` (F841) | +| `reference_listener.py:318` | `smoothed_onset` → `_smoothed_onset` (F841) | +| `reference_listener.py:343` | `total_frames` → `_total_frames` (F841) | +| `reference_listener.py:2594` | `'Sample'` tipo hint → `Any` (F821 — `Sample` no definido en scope) | +| `reference_listener.py:2600` | `'Sample'` tipo hint → `Any` (F821 — `Sample` no definido en scope) | +| `opencode.json` | Creado con MCP registrado y todos los permisos en `allow` | diff --git a/AbletonMCP_AI/MCP_Server/vector_manager.py b/AbletonMCP_AI/MCP_Server/vector_manager.py new file mode 100644 index 0000000..d5687a8 --- /dev/null +++ b/AbletonMCP_AI/MCP_Server/vector_manager.py @@ -0,0 +1,163 @@ +import os +import json +import logging +from pathlib import Path +from typing import List, Dict, Tuple + +try: + from sentence_transformers import SentenceTransformer + from sklearn.metrics.pairwise import cosine_similarity + import numpy as np + HAS_ML = True +except ImportError: + HAS_ML = False + +logger = logging.getLogger("VectorManager") +logging.basicConfig(level=logging.INFO) + +class VectorManager: + def __init__(self, library_dir: str): + self.library_dir = Path(library_dir) + self.index_file = self.library_dir / ".sample_embeddings.json" + + self.model = None + self.embeddings = [] + self.metadata = [] + + if HAS_ML: + try: + # Load a very lightweight model for fast embeddings + logger.info("Loading sentence-transformers model (all-MiniLM-L6-v2)...") + self.model = SentenceTransformer('all-MiniLM-L6-v2') + except Exception as e: + logger.error(f"Failed to load embedding model: {e}") + + self._load_or_build_index() + + def _load_or_build_index(self): + if self.index_file.exists(): + logger.info("Loading existing vector index...") + try: + with open(self.index_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.metadata = data.get('metadata', []) + + if HAS_ML and 'embeddings' in data: + self.embeddings = np.array(data['embeddings']) + else: + logger.warning("No embeddings found in loaded index.") + except Exception as e: + logger.error(f"Failed to load index: {e}") + self._build_index() + else: + self._build_index() + + def _build_index(self): + logger.info(f"Scanning library {self.library_dir} for new embeddings...") + extensions = {'.wav', '.aif', '.aiff', '.mp3'} + + files_to_process = [] + for ext in extensions: + files_to_process.extend(self.library_dir.rglob('*' + ext)) + files_to_process.extend(self.library_dir.rglob('*' + ext.upper())) + + if not files_to_process: + logger.warning(f"No audio files found in {self.library_dir} to embed.") + return + + texts_to_embed = [] + self.metadata = [] + + for f in set(files_to_process): + # Clean up the name for better semantic understanding + name = f.stem + clean_name = name.replace('_', ' ').replace('-', ' ').lower() + + # Use relative path as part of the context since folders represent duration and type + try: + rel_path = f.relative_to(self.library_dir) + parts = rel_path.parts[:-1] + path_context = " ".join(parts).lower() + except ValueError: + path_context = "" + + description = f"{clean_name} {path_context}" + texts_to_embed.append(description) + + self.metadata.append({ + 'path': str(f), + 'name': name, + 'description': description + }) + + if HAS_ML and self.model: + logger.info(f"Generating vectors for {len(texts_to_embed)} samples. This might take a moment...") + embeddings = self.model.encode(texts_to_embed) + self.embeddings = embeddings + + # Save the vectors + with open(self.index_file, 'w', encoding='utf-8') as f: + json.dump({ + 'metadata': self.metadata, + 'embeddings': embeddings.tolist() + }, f) + logger.info(f"Saved {len(self.metadata)} embeddings to {self.index_file}.") + else: + logger.error("ML libraries not installed. Run 'pip install sentence-transformers scikit-learn numpy'") + + def semantic_search(self, query: str, limit: int = 5) -> List[Dict]: + """ + Returns a list of metadata dicts sorted by semantic relevance down to the limit. + Fallback to basic substring matching if ML is unavailable. + """ + if not HAS_ML or self.model is None or len(self.embeddings) == 0: + logger.warning("ML unavailable, falling back to substring search.") + return self._fallback_search(query, limit) + + logger.info(f"Performing semantic search for: '{query}'") + query_emb = self.model.encode([query]) + + # Calculate cosine similarity between query and all stored embeddings + similarities = cosine_similarity(query_emb, self.embeddings)[0] + + # Get top indices + top_indices = np.argsort(similarities)[::-1][:limit] + + results = [] + for idx in top_indices: + score = float(similarities[idx]) + meta = self.metadata[idx].copy() + meta['score'] = score + results.append(meta) + + return results + + def _fallback_search(self, query: str, limit: int = 5) -> List[Dict]: + query = query.lower() + scored = [] + for m in self.metadata: + score = 0 + if query in m['name'].lower(): + score += 10 + if query in m['description'].lower(): + score += 5 + + if score > 0: + scored.append((score, m)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [m for s, m in scored[:limit]] + +if __name__ == "__main__": + import sys + if len(sys.argv) > 1: + path = sys.argv[1] + vm = VectorManager(path) + if len(sys.argv) > 2: + query = sys.argv[2] + res = vm.semantic_search(query) + print("Search Results for", query) + for r in res: + print(r['score'], r['name'], r['path']) + else: + print("Usage: python vector_manager.py [search_query]") diff --git a/AbletonMCP_AI/PRO_DJ_ROADMAP.md b/AbletonMCP_AI/PRO_DJ_ROADMAP.md new file mode 100644 index 0000000..198a1d1 --- /dev/null +++ b/AbletonMCP_AI/PRO_DJ_ROADMAP.md @@ -0,0 +1,344 @@ +# 🎛️ AbletonMCP AI — Roadmap a Calidad DJ Profesional + +> **Documento Maestro** | 28-Mar-2026 +> Basado en: `server.py`, `sample_selector.py`, `glm_coherence_diversity.md`, `glm_sample_intelligence.md`, `human_feel.md` +> +> Objetivo: evolucionar el sistema desde un generador MIDI funcional a una herramienta de producción Tech House de **calidad de lanzamiento DJ profesional (DJ Hertz, Innervisions, Get Physical)**. + +--- + +## 🗺️ Mapa de fases + +| Fase | Nombre | Estado | Tareas | +|------|--------|--------|--------| +| **0** | Fundación y estabilidad | ✅ DONE | T001–T010 | +| **1** | Inteligencia de samples | 🔴 P1 | T011–T024 | +| **2** | Coherencia musical & Paleta | 🔴 P1 | T025–T039 | +| **3** | Human Feel & Dinámicas | 🟠 P2 | T040–T050 | +| **4** | Soundscape & Tonal | 🟠 P2 | T051–T062 | +| **5** | Arranjo y estructura DJ | 🟡 P3 | T063–T077 | +| **6** | Masterización & Lanzamiento | 🟡 P3 | T078–T090 | +| **7** | IA Autónoma y Aprendizaje | 🔵 FUTURO | T091–T110 | + +--- + +## FASE 0 — Fundación y Estabilidad ✅ + +- [x] **T001** — Migrar proyecto a `C:\Users\ren\AbletonMCP_AI` con junction a `ProgramData` +- [x] **T002** — Crear `start_server.bat` con `PYTHONPATH` correcto +- [x] **T003** — Sincronizar `opencode.json` y `.opencode.json` apuntando a ruta nueva +- [x] **T004** — Verificar que `server.py` arranca sin errores en log +- [x] **T005** — Confirmar que `SampleManager` carga la librería completa en `librerias\all_tracks` +- [x] **T006** — Confirmar conexión MCP activa y visible en cliente AI +- [x] **T007** — Resolver permisos NTFS en `ProgramData` para edición directa +- [x] **T008** — Configurar logging a nivel INFO en producción +- [x] **T009** — Integrar estructura de errores: `MCPError`, `ValidationError`, `TimeoutError` +- [x] **T010** — Pipeline end-to-end: generar 1 canción y verificar que carga en Ableton + +--- + +## FASE 1 — Inteligencia de Samples 🔴 PRIORIDAD MÁXIMA + +> **Goal**: De 800 samples disponibles, el sistema usa todos inteligentemente, sin repetir, con coherencia tonal. + +### 1.A — Fix de repetición (impacto inmediato) + +- [ ] **T011** — `server.py → _find_library_file()`: aumentar `limit` de semantic search de `10` a `50` + - Bug actual: solo se evalúan 10 resultados; si tienen penalización de diversidad, el sistema queda atascado en los mismos archivos. + +- [ ] **T012** — `sample_selector.py → select_sample()`: shuffled candidate pool con `session_seed` + - Antes de scorear, aplicar `random.shuffle()` al pool con seed basado en timestamp de la generación. + +- [ ] **T013** — `server.py → _build_audio_fallback_sample_paths()`: bucket sampling por subcarpeta + - Limitar a máximo 15 archivos por subcarpeta. Garantiza que samples de distintas colecciones entren al pool. + +- [ ] **T014** — Verificar y reparar persistencia de `sample_history.json` entre reinicios del servidor + - Si el archivo no existe al arrancar: inicializar con diccionario vacío (no con None). + +- [ ] **T015** — Herramienta MCP `get_sample_coverage_report()` + - Devolver: % de cobertura por subcarpeta, samples más usados, samples nunca usados. + +### 1.B — Análisis espectral en indexado + +- [ ] **T016** — `vector_manager.py → _build_index()`: agregar llamada a `AudioAnalyzer.analyze()` para cada sample + - Extraer y guardar en `.sample_embeddings.json`: `key`, `key_confidence`, `spectral_centroid`, `is_harmonic`. + - Flag `--skip-audio-analysis` para rebuild rápido en desarrollo. + +- [ ] **T017** — `sample_selector.py → _calculate_sample_score()`: agregar factor `brightness_fit` (peso 0.10) + - `atmos`, `pad`, `drone`: penalizar `spectral_centroid > 8000 Hz`. + - `bass`, `sub_bass`: penalizar `spectral_centroid > 3000 Hz`. + - `lead`, `chord`: preferir key dentro de ±1 quinta de la key del proyecto. + +- [ ] **T018** — Mejorar el embed de texto en `vector_manager.py` para incluir info espectral + - Formato: `"kick 808 drums bright=low harmonic=no key=None"` + +- [ ] **T019** — Validar que `audio_analyzer.py` con `librosa` detecta key en ≥70% de samples + - Script de prueba: analizar 50 archivos aleatorios y reportar `key`, `confidence`. + +- [ ] **T020** — Agregar campo `is_tonal` al metadata del índice + - `True` para: chords, pad, lead, bass, pluck, arp, drone. + - `False` para: kick, snare, hat, crash, fill. + +### 1.C — Sistema de fatiga persistente + +- [ ] **T021** — Crear `sample_fatigue.json` en `~/.abletonmcp_ai/` + - Estructura: `{path: {role: {uses: int, last_used: timestamp}}}` + - Reemplaza gradualmente `_recent_sample_diversity_memory`. + +- [ ] **T022** — Factor de fatiga continuo (vs. binario actual) + - 0 usos: sin penalización → `fatigue_factor = 1.0` + - 1-3 usos: `fatigue_factor = 0.75` + - 4-10 usos: `fatigue_factor = 0.50` + - 10+ usos: `fatigue_factor = 0.20` (casi bloqueado) + +- [ ] **T023** — Herramienta MCP `reset_sample_fatigue(role=None)` + - Sin `role`: resetear toda la fatiga. Con `role`: resetear solo ese rol. + +- [ ] **T024** — Herramienta MCP `get_sample_fatigue_report()` + - Top-10 samples más usados por rol con conteos y timestamps. + +--- + +## FASE 2 — Coherencia Musical & Sistema de Paleta 🔴 PRIORIDAD MÁXIMA + +> **Goal**: Cada canción tiene una "identidad sonora" coherente. Drums, bass y music suenan como si vinieran de la misma sesión. + +### 2.A — Palette Lock + +- [ ] **T025** — Selección de "folder ancla" por bus al inicio de cada generación + - Elegir aleatoriamente de las carpetas **menos usadas** (via Coverage Wheel). + - Mapear: `drums_anchor`, `bass_anchor`, `music_anchor` (FX libre). + +- [ ] **T026** — `sample_selector.py → _calculate_sample_score()`: agregar factor `palette_bonus` + - Folder ancla: `score *= 1.4` | Folder compatible: `score *= 1.2` | Diferente: `score *= 0.9` + +- [ ] **T027** — Guardar la palette en el manifest de generación + - Formato: `{"palette": {"drums": "Splice/Techno/Kit_A", "bass": "SM/TechHouse/Bass"}}` + +- [ ] **T028** — Herramienta `set_palette_lock(drums, bass, music)` para override manual + +### 2.B — Coverage Wheel + +- [ ] **T029** — Crear `collection_coverage.json` en `~/.abletonmcp_ai/` + - Estructura: `{folder_path: {uses: int, last_used: timestamp}}` + +- [ ] **T030** — Al terminar cada generación: actualizar Coverage Wheel con carpetas usadas + +- [ ] **T031** — Lógica de selección de ancla: weighted random sampling por frescura + - `freshness = max(0, 10 - uses_last_10_gens)` → elegir ancla con mayor freshness. + +- [ ] **T032** — Herramienta MCP `get_coverage_wheel_report()` → heatmap de uso por carpeta + +### 2.C — Wild Card Injection + +- [ ] **T033** — Seleccionar 2-3 roles "wild card" por generación + - Para wild cards: solo 2 filtros duros (rol correcto + duración válida), sin penalización. + +- [ ] **T034** — `select_sample_wildcard(role, seed)` en `SampleSelector` + - Log: `"Wild card selected: {path} for role {role}"` + +### 2.D — Section Casting + +- [ ] **T035** — Definir `ROLE_SECTION_VARIANTS` en `song_generator.py` + ```python + ROLE_SECTION_VARIANTS = { + 'top_loop': {'intro': 'minimal filtered subtle', 'drop': 'full punchy driving', 'break': 'sparse reversed'}, + 'atmos': {'intro': 'atmospheric wide', 'drop': 'driving textured', 'break': 'deep long ambient'}, + 'pad': {'intro': 'soft subtle', 'drop': 'full wide powerful', 'break': 'evolving textured'}, + 'vocal_chop': {'intro': 'minimal', 'drop': 'aggressive chopped', 'break': 'reversed distant'}, + } + ``` + +- [ ] **T036** — `server.py → _find_library_file()`: pasar `section` como modificador del query + - `query_modified = f"{base_query} {ROLE_SECTION_VARIANTS[role][section]}"` + +- [ ] **T037** — Seleccionar samples distintos por sección para roles con variantes + +### 2.E — Fingerprint tonal de la canción + +- [ ] **T038** — Al seleccionar el primer sample armónico: extraer fingerprint + - `fingerprint = {key, spectral_centroid, is_major, brightness_category}` → guardar en contexto de generación. + +- [ ] **T039** — Penalización de fingerprint mismatch para samples armónicos subsiguientes + - Key a más de 2 quintas: `score *= 0.6` | Brightness muy diferente (>3000 Hz): `score *= 0.8` + +--- + +## FASE 3 — Human Feel & Dinámicas 🟠 PRIORIDAD ALTA + +> **Goal**: La música respira. Hay tensión-release, fades naturales, groove humano. + +- [ ] **T040** — `Remote_Script.py`: agregar comando `write_clip_envelope` + - Parámetros: `track_index`, `clip_index`, `points: List[(time_beats, value)]` + +- [ ] **T041** — Herramienta `apply_clip_fades(track_index, section, fade_in_bars, fade_out_bars)` + - Intro: fade-in 4-8 bars en kick, bass, top loops. + - Outro: fade-out simétrico. Break: fade-down al inicio + fade-up antes del drop. + +- [ ] **T042** — Herramienta `write_volume_automation(track_index, curve_type, section_map)` + - Curves: `linear`, `exponential`, `s_curve`, `punch`. + - Build: curva exponencial 0.5 → 0.85 en bus de music. + +- [ ] **T043** — Implementar curvas de volumen por sección en `song_generator.py` + - Intro: music al 60% → building → 100% en drop. Break: drums 30%, music+atmos 70%. + +- [ ] **T044** — Herramienta `inject_dynamic_variation(track_index, role)` + - Ghost notes MIDI: velocidad 20-40. Downbeats: 90-110. Upbeats: 60-80. + - Para audio clips: micro-automatizaciones de ±3dB en forma de LFO lento. + +- [ ] **T045** — Herramienta `apply_sidechain_pump(intensity, style)` + - `'subtle'`: pump mínimo solo en drop. + - `'jackin'`: pump pronunciado cada beat (tech house clásico). + - `'breathing'`: pump lento cada 2 beats. + +- [ ] **T046** — Variación de velocidad MIDI por sección + - Intro: velocidades −15%. Drop: plenas + ghost notes. + +- [ ] **T047** — Herramienta `apply_loop_variation(role, sections_map)` + - Intro: top loop filtrado/suave. Drop: agresivo/abierto. Break: swing alto. + +- [ ] **T048** — Herramienta `inject_pattern_fills(track_index, fill_density, section)` + - Snare rolls, flams, tom fills, hi-hat busteos. + - Densidad: `'sparse'` (1 cada 8 bars), `'medium'`, `'heavy'` (cada 2 bars). + +- [ ] **T049** — Herramienta `apply_swing_to_clip(track_index, clip_index, swing_percent)` + - Por sección: intro 8%, drop 14%, break 18%. + +- [ ] **T050** — Herramienta paraguas `humanize_set(intensity)` (0.3 / 0.6 / 1.0) + +--- + +## FASE 4 — Soundscape & Tonal 🟠 PRIORIDAD ALTA + +> **Goal**: Identidad harmónica y espectral definida. No hay samples que "rompen" el ambiente. + +- [ ] **T051** — Análisis masivo de key con Krumhansl-Schmuckler durante el indexado +- [ ] **T052** — `KEY_COMPATIBILITY_MATRIX` con scores 0-1 para cada par de keys +- [ ] **T053** — Integrar `KEY_COMPATIBILITY_MATRIX` en `_calculate_sample_score()` (factor 0.25) +- [ ] **T054** — Detección de `project_key` al iniciar generación (manual o inferida) +- [ ] **T055** — Rechazar samples con `key_compatibility < 0.40` para roles críticos (chords, lead, pad) +- [ ] **T056** — Definir `BRIGHTNESS_RANGES` óptimas por rol (sub_bass, bass, kick, pad, lead, atmos…) +- [ ] **T057** — Factor `spectral_fit` en `_calculate_sample_score()` (peso 0.10) +- [ ] **T058** — Paneo espectral inteligente por sección (dinámico según `AUDIO_LAYER_MIX_PROFILES`) +- [ ] **T059** — Filtros de frecuencia automáticos por sección (high-pass en intro, high-cut en break) +- [ ] **T060** — Brightness embedding de 8 bandas por sample en el índice +- [ ] **T061** — Tags automáticos de color espectral: `dark`, `neutral`, `bright`, `warm`, `harsh` +- [ ] **T062** — Herramienta `analyze_mix_spectrum()` → análisis del master en tiempo real + +--- + +## FASE 5 — Arranjo y Estructura DJ Profesional 🟡 PRIORIDAD P3 + +> **Goal**: Estructura DJ real: intro largo para mezclar, drops definidos, breaks tensos, outro largo. + +- [ ] **T063** — Definir `DJ_ARRANGEMENT_TEMPLATES` por subgénero (tech_house, deep_house, techno_minimal) + ```python + 'tech_house': { + 'intro': {'bars': 16, 'elements': ['kick','bass','hat'], 'energy': 0.4}, + 'build_1': {'bars': 8, 'elements': ['+perc','+top_loop'], 'energy': 0.6}, + 'drop_1': {'bars': 16, 'elements': ['full'], 'energy': 1.0}, + 'break': {'bars': 8, 'elements': ['-kick','+atmos','filter_bass'], 'energy': 0.5}, + 'drop_2': {'bars': 16, 'elements': ['full','+vocal_peak'], 'energy': 1.0}, + 'outro': {'bars': 16, 'elements': ['-vocal','-music'], 'energy': 0.4}, + } + ``` + +- [ ] **T064** — `generate_arrangement(template, length_bars)` → genera toda la sesión en Arrangement view +- [ ] **T065** — Intro DJ-compatible de mínimo 16 bars (solo kick + bass + hat) +- [ ] **T066** — Outro DJ-compatible de mínimo 16 bars (misma lógica inversa) +- [ ] **T067** — Loop markers automáticos en puntos clave (drop marcado como loop 16 bars) +- [ ] **T068** — Variación de pattern de kick por sección (ghost notes en build, reverse en break) +- [ ] **T069** — Hi-hat evolution: de closed a open gradualmente por sección +- [ ] **T070** — Bassline evolution: de root-note en intro a melodic walk en drop_2 +- [ ] **T071** — Herramienta `inject_transition_fx(type, position_bar)` (riser / crash / snare_roll) +- [ ] **T072** — Filter sweep automation en transiciones (high-pass sube 8 bars antes del drop) +- [ ] **T073** — Reverb tail automation en breaks (reverb 0% → 40% → 0%) +- [ ] **T074** — Pitch automation: riser en últimos 4 beats del break, snap al drop +- [ ] **T075** — Micro-timing "push" del groove (kick −5ms, bass +8ms) para feel orgánico +- [ ] **T076** — `GROOVE_TEMPLATES` por subgénero: `tech_house_drop`, `tech_house_break`, `deep_house_drop` +- [ ] **T077** — `apply_groove_template(section, template_name)` aplicado automáticamente al arrangement + +--- + +## FASE 6 — Masterización & Lanzamiento 🟡 PRIORIDAD P3 + +> **Goal**: La canción suena a promo de label internacional, lista para Beatport. + +- [ ] **T078** — Validar `ROLE_GAIN_CALIBRATION` prácticamente: kick −8 LUFS, bass −10 LUFS +- [ ] **T079** — Herramienta `calibrate_gain_staging()` → medir LUFS de cada bus y ajustar faders +- [ ] **T080** — Verificar headroom en Master Track (≥ −6 dBFS antes del limitador) +- [ ] **T081** — `BUS DRUMS`: parallel compression calibrada (attack 30ms, release 100ms) +- [ ] **T082** — `BUS BASS`: sub en mono, high-cut automático por encima de 300Hz +- [ ] **T083** — `BUS MUSIC`: glue compressor 2:1 + stereo widener solo en mid-high +- [ ] **T084** — Verificar sends de FX (Space/Echo/Heat/Glue) coherentes con `AUDIO_LAYER_MIX_PROFILES` +- [ ] **T085** — Herramienta `run_mix_quality_check()`: LUFS, peak, RMS, balance L/R, correlation mono +- [ ] **T086** — Flags automáticos de issues críticos (clip, desbalance L/R, kick muy silencioso) +- [ ] **T087** — Herramienta `export_stem_mixdown(stem_config)` → stems 24-bit / 44.1kHz WAV +- [ ] **T088** — Generación automática de metadata Beatport (BPM, key, género desde el manifest) +- [ ] **T089** — A/B testing: generar 3 variantes del mismo drop con diferentes palette locks +- [ ] **T090** — Herramienta `analyze_reference_track(file_path)` → extraer BPM, key, LUFS, spectral balance del track de referencia + +--- + +## FASE 7 — IA Autónoma y Aprendizaje 🔵 FUTURO + +> **Goal**: El sistema aprende de las preferencias del usuario y mejora con cada sesión. + +- [ ] **T091** — Sistema de rating `rate_generation(session_id, score: 1-5, notes)` +- [ ] **T092** — Feedback loop: samples de sesiones bien puntuadas tienen menor fatiga futura +- [ ] **T093** — Predicción de preferencias de palette por BPM/key (con 20+ generaciones) +- [ ] **T094** — Análisis de tendencias de la librería: identificar carpetas con mala performance histórica +- [ ] **T095** — Modo "Autopilot DJ": 16 tracks concatenados con Palette Lock linked entre sí +- [ ] **T096** — Herramienta `generate_dj_set(duration_hours, style_evolution)` → set de 4 horas completo +- [ ] **T097** — Análisis de referencia de Beatport top-100 Tech House (BPM, keys, spectral profiles) +- [ ] **T098** — Hot zone detection: identificar características comunes de drops con mejor rating +- [ ] **T099** — Medir si el set "mueve" via detección de variación de volumen en Ableton (proxy energía) +- [ ] **T100** — Herramienta `auto_improve_set(feedback_json)` → regenerar secciones con bajo score sin tocar las exitosas + +--- + +## 🛠️ Infraestructura y Soporte + +- [ ] **T101** — Tests de regresión para `sample_selector.py` (repetición, palette lock, key match) +- [ ] **T102** — Benchmark de performance del indexado (base: 800 samples < 20 min) +- [ ] **T103** — Hot reload de configuración sin reiniciar el servidor +- [ ] **T104** — `howto.md` actualizado con ejemplos JSON-RPC explícitos por herramienta +- [ ] **T105** — CI automático en Gitea con webhooks + badge de status +- [ ] **T106** — `CHANGELOG.md` con versiones del sistema +- [ ] **T107** — Backup diario de `~/.abletonmcp_ai/` (sample_history, fatigue, coverage) +- [ ] **T108** — Dashboard de métricas: `get_system_metrics()` → generaciones totales, cobertura %, promedio estrellas +- [ ] **T109** — Soporte para Deep House, Minimal Techno, Afro House en `STYLE_CONFIGS` +- [ ] **T110** — Script `import_sample_pack(folder, genre_tag, collection_name)` → indexa + actualiza Coverage Wheel + +--- + +## 📊 Métricas de éxito por Fase + +| Fase | KPI | Target | +|------|-----|--------| +| 1 | % samples únicos en 20 generaciones | > 85% | +| 1 | % samples con key detectada | > 60% | +| 2 | Coherencia de palette (% samples del folder ancla) | > 65% | +| 2 | Coverage de librería en 20 generaciones | > 80% carpetas usadas | +| 3 | Diferencia de energía drop vs break | > 6 dB LUFS | +| 4 | Key mismatch rate en samples armónicos | < 10% | +| 5 | Intro/Outro duration DJ-compatible | ≥ 16 bars | +| 6 | LUFS integrado del master | −10 a −8 LUFS | +| 6 | Headroom en master | ≥ 0.3 dBTP | + +--- + +## 🚀 Orden de implementación recomendado + +| Sprint | Tareas | Objetivo | +|--------|--------|----------| +| **1** (inmediato) | T011 → T015 | Fix de repetición de samples | +| **2** | T025 → T032 | Palette Lock + Coverage Wheel | +| **3** | T040 → T050 | Human Feel básico | +| **4** | T016 → T020, T051 → T055 | Tonal intelligence | +| **5** | T063 → T072 | Estructura DJ | +| **6** | T078 → T087 | Gain staging + export | + +--- + +*Documento vivo — actualizar con cada sprint completado.* diff --git a/AbletonMCP_AI/diversity_memory.json b/AbletonMCP_AI/diversity_memory.json new file mode 100644 index 0000000..4e4665d --- /dev/null +++ b/AbletonMCP_AI/diversity_memory.json @@ -0,0 +1,19 @@ +{ + "used_families": {}, + "used_paths": { + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Dubdogz & Jude & Frank - ININNA TORA (Extended Version) [@danielcarmona_dj].mp3": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Clap 5.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 9.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 3.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Kick Hit 03.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 12.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Hat Hit 07.wav": 2, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 5.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Kick Hit 10.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 14.wav": 1, + "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Kit_01_OHH_A#_125.wav": 1 + }, + "generation_count": 5, + "last_updated": "2026-03-28T22:48:13.238517", + "version": "1.0" +} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/.gitignore b/AbletonMCP_AI_BAK_20260328_200801/.gitignore new file mode 100644 index 0000000..adc2402 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/.gitignore @@ -0,0 +1,46 @@ +__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +.idea/ +.vscode/ +*.log +*.tmp +*.bak +*.asd +*.als +*.wav +*.aif +*.aiff +*.flac +*.ogg +*.mp3 +exports/ +render/ +renders/ +stems/ +temp/ +tmp/ +/automation/telegram.local.json +/automation/wsl_runtime/ +/automation/wsl.local.env +/automation/wsl/.env +/automation/runs/ +/automation/.task_queue.tmp* + +# Temp and debug files +*_errors*.txt +*.patch +fix.py +update_opencode.py +grant_permissions.py +GLM_TASK_*_REPORT.md +glmwork.md + +# Library paths (user-specific) +librerias/ +sample/ diff --git a/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md b/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md new file mode 100644 index 0000000..02fc672 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md @@ -0,0 +1,727 @@ +# AbletonMCP-AI Full Handoff + +This file is the broadest handoff in the repo. + +If another AI needs to retake the project with minimal context loss, this is the file to read first. + +Project root: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI` + +Current local branch: +- `main` + +Last pushed commit at the moment this file was updated: +- `2a0d2f3dbf5f89b18690fee2a2659957f81b8191` + +## Read Order + +Read in this order: + +1. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\CLAUDE.md` +2. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\README.md` +3. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\AI_HANDOFF.md` +4. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\ARCHITECTURE.md` +5. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\MCP_TOOLS.md` +6. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\REMOTE_PROTOCOL.md` +7. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\PROJECT_CONTEXT.md` +8. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\GPU_DIRECTML.md` + +Useful secondary docs: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\ABLETUNES_TEMPLATE_NOTES.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\SAMPLE_SYSTEM_README.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\README.md` + +## What This Project Is + +AbletonMCP-AI is a hybrid system to control Ableton Live 12 from MCP, generate long editable arrangements, analyze reference tracks, retrieve similar material from a local sample library, and build original projects that feel closer to a real producer workflow than to a loop toy. + +The desired output is: + +- always Arrangement View +- editable tracks and clips +- many specialized roles +- buses and returns +- original output, not stems from the reference track + +## Core Design Rules That Must Not Be Lost + +- Arrangement-first is mandatory. +- Reference audio is for analysis, not plagiarism. +- Do not use stems from the target song in the final output. +- The system must keep working even if the M4L path is incomplete. +- Stable fallback is better than a flashy broken feature. +- The local sample library is the primary sound source. +- Validation and diagnosis matter because the stack is large and brittle. + +## Important Paths + +Main runtime: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\__init__.py` + +MCP server: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py` + +Music generator: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py` + +Reference analysis: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\reference_listener.py` + +Sample selection: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\sample_selector.py` + +Audio resampling: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\audio_resampler.py` + +Socket smoke test: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\socket_smoke_test.py` + +Segment RAG builder: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\segment_rag_builder.py` + +Local library: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks` + +Reference folder: +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample` + +Ableton log: +- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\Log.txt` + +Recovery popup file: +- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\CrashRecoveryInfo.cfg` + +User library: +- `C:\Users\ren\Documents\Ableton\User Library` + +## External Assets And References Used During Development + +Reference tracks that shaped the direction: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample\Eli Brown x GeezLy - Me Gusta.mp3` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample\Mr. Pauer, Goyo - Quimica (Video Oficial).mp3` + +Producer template reference pack: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\Abletunes_Free_Templates_Pack` + +The reference tracks were used for: + +- BPM and key estimation +- section and energy direction +- sample retrieval guidance +- stylistic remake goals + +They were not supposed to be used as final stems. + +## The Story From The Beginning Until Now + +### Phase 0: Initial Goal + +The original goal was not only to make sounds in Ableton, but to give the system the ability to: + +- receive a prompt or a reference MP3 +- understand the style and structure +- choose similar sounds from the local library +- generate a long arrangement that feels professionally produced +- keep the result editable inside Ableton + +Very early it became clear that a plain Session View loop machine was not enough. + +### Phase 1: Make The Remote Script Actually Work + +The first major work was stabilizing the remote layer between MCP and Ableton. + +Main problems solved in that phase: + +- command naming mismatches between MCP and the Remote Script +- parameter normalization like `track_index`, `clip_index`, `scene_index` +- note writing API mismatch in Live +- socket protocol mismatch +- stale or broken sample manager initialization + +This phase made the project usable enough to: + +- create tracks +- create clips +- write MIDI +- query session state +- build the first generated projects + +### Phase 2: Arrangement-First Pivot + +At that point the output still behaved too much like Session clips and loops. The user explicitly wanted to see everything in Arrangement View. + +That created the second major architectural pivot: + +1. generate blueprint in Session +2. commit Session to Arrangement in ordered scene playback +3. place audio fallback and overlays in Arrangement + +This became the stable route. + +Important lesson: + +- precreating certain audio tracks before the Session to Arrangement commit produced silent or broken sets +- the stable route is still: Session blueprint first, Arrangement commit second, audio layers after that + +### Phase 3: Richer Project Shape + +Once Arrangement-first worked, the next problem was musical complexity. The output felt like repeated loops. + +The generator was expanded with: + +- more track roles +- section-aware pattern generation +- richer drums, bass, melodic and FX layers +- scene naming and locators +- guide tracks +- more realistic arrangement structures + +Important roles that became standard: + +- kick +- clap +- snare fill +- hat closed +- hat open +- top loop +- percussion +- ride +- tom fill +- sub bass +- bass +- drone +- chords +- pluck +- vocal chop +- stab +- pad +- arp +- lead +- counter +- crash +- reverse FX +- riser FX +- impact FX +- atmos +- plus many `AUDIO ...` tracks + +### Phase 4: Local Library As Primary Source + +The next major issue was weak selection quality. At one point the code was pointed to a small mirror path and not to the real large library. + +The real usable library was: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks` + +That directory had hundreds of real audio assets, while the old fallback mirror only had a few dozen. + +Fixing that changed the quality of: + +- drum matching +- bass loop matching +- vocal loop matching +- FX and atmos selection + +This was a major turning point because the generated output stopped sounding starved. + +### Phase 5: Reference-Led Reconstruction + +After the library path was fixed, the project started using references more seriously. + +The target workflow became: + +- analyze the reference +- infer BPM, key, energy, style and section behavior +- find similar sounds in the local library +- reconstruct a new original track + +There was a temporary detour where stems of a reference song were materialized for analysis, but that was explicitly rejected because the desired product is not a stem-based copy workflow. + +The rule became strict: + +- no final stems from the reference +- only original reconstruction using local assets and generated MIDI + +### Phase 6: Buses, Returns, Master, And Production Logic + +The project then moved from "long loop" to "production-shaped session". + +Major additions: + +- real bus tracks +- return tracks +- track routing into buses +- role-based mixing +- return snapshots by section +- master chain blueprint + +Typical bus layout now: + +- drums +- bass +- music +- vocal +- FX + +Typical return layout now: + +- space +- echo +- heat +- glue + +This phase made the result feel more like a produced project and less like independent loops. + +### Phase 7: Template Analysis + +The project analyzed professional Abletunes templates to absorb real producer patterns. + +Main conclusions: + +- professional templates are Arrangement-heavy +- there are many specialized layers, not just one drum loop and one bass loop +- sidechain triggers, transitions, buses, returns and printed audio are common +- arrangement blocks are often 16, 32 or 64 bars +- heavy automation is normal + +This analysis informed later changes in: + +- section shape +- layer count +- transition behavior +- bus logic + +### Phase 8: Audio Fallback And Hybrid Output + +The system learned to combine: + +- MIDI and stock instruments +- local audio loops and hits +- special `AUDIO ...` overlays + +This was critical because: + +- sometimes stock-device generation gives editability and musical logic +- sometimes local audio assets give the genre-specific realism that MIDI alone cannot deliver + +So the stack became hybrid by design, not by accident. + +### Phase 9: Audio Resampling And Derived FX + +Then came the derived transition layer. + +The system added support for: + +- reverse FX +- riser +- downlifter +- stutter + +This area improved through: + +- `audio_resampler.py` +- reference-driven placement +- later bugfixes for short clips and defensive rendering + +One real bug that had to be solved: + +- `AUDIO RESAMPLE STUTTER` failed until the resampler and short-clip FFT handling were hardened + +That was fixed later and validated in real Live runs. + +### Phase 10: GPU And Deeper Reference Listening + +The user specifically asked to use GPU if possible. + +The system moved toward: + +- `DirectML` +- more expensive analysis of the reference +- segmented analysis at multiple window sizes +- heavier similarity scoring + +This became one of the most important changes for retrieval quality. + +The reference listener started doing: + +- segmenting the reference into short windows +- scoring by role +- deeper reranking +- using local metadata and cache + +This was the start of an audio-retrieval-style workflow rather than simple filename matching. + +### Phase 11: Segment RAG Direction + +To go further, the project started building a segment-level retrieval cache for the local library. + +That work added: + +- persistent per-segment cache files +- a segment builder CLI +- partial index expansion over the real library + +This is not a text RAG in the usual sense. It is closer to: + +- segmented audio retrieval +- coarse search plus rerank +- role-aware filtering +- diversity constraints + +This remains one of the most promising long-term directions for better remake quality. + +### Phase 12: GLM Workflow + +At some point token efficiency became a concern, so GLM-5 was introduced as a worker model. + +The workflow that proved useful was: + +1. Codex writes a narrow `.md` task +2. GLM edits only 1 to 3 related files +3. Codex reviews the diff +4. Codex corrects technical mistakes and validates the runtime + +What GLM was good at: + +- narrow feature implementation +- heuristic expansion +- helper tools +- reports and manifests + +What GLM was bad at: + +- declaring things complete too early +- runtime-sensitive work without supervision +- architectural judgment +- avoiding diff inflation + +Practical verdict: + +- useful as a worker +- not reliable enough as the sole closer of large features + +### Phase 13: Temporary WSL / n8n / Local Gitea Automation Detour + +There was a detour into WSL orchestration, n8n, local Gitea and Telegram loops. + +Scaffolding was generated for: + +- WSL deployment +- Docker Compose +- n8n flows +- Telegram notifications +- GLM to Codex automation loops + +That stack produced a lot of files under: + +- `automation\` +- `automation\wsl\` + +But the key lesson was: + +- the deployment summary was overstated +- the generated stack was not a truly finished deployment +- the main product value still lives in the music system, not in orchestration + +Because of that, the project intentionally pivoted back to the flow that worked: + +- Codex writes focused `.md` +- GLM does bounded work +- Codex reviews and fixes + +### Phase 14: Retrieval Hardening, Pattern Banks, Transition Materialization + +After the orchestration detour, the roadmap was re-centered on the actual product. + +Task batches improved: + +- role-safe retrieval +- repetition penalties +- more section pattern banks +- transition event materialization + +This reduced some of the "same loop again" feeling, but did not solve everything. + +### Phase 15: Device Automation Snapshots + +The latest work pushed into: + +- track device parameter snapshots +- return device parameter snapshots +- master section snapshots +- automation summaries + +GLM implemented part of that, but the raw result was not correct. + +The real fixes applied afterward were: + +- use the already existing `SECTION_DEVICE_AUTOMATION` instead of duplicate tables +- use the already existing `MASTER_DEVICE_AUTOMATION` +- flatten `device_parameters` into the format the server actually applies +- make the server accept both flat and nested payloads defensively +- add `mix_automation_summary` +- add `mix_automation_warnings` + +This work lives mainly in: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py` + +### Phase 16: Fix for "Only Piano" Issue (Audio Samples Not Loading) + +The user reported: "lo que me generaste solo tiene algunos pianos, nada de sonidos de mi biblioteca!" - the generated tracks only had piano/MIDI sounds, no actual audio samples from the local library. + +Root causes found and fixed: + +1. **`ROLE_PATTERNS` used non-recursive globs** in `reference_listener.py` lines 1228-1254: + - Patterns like `'*Kick*.wav'` couldn't match files in subdirectories like `loops/kick/` + - Fixed by changing to recursive patterns: `'**/*Kick*.wav'` + +2. **Method resolution bug** in `reference_listener.py`: + - `_get_role_section_features` and `_section_character_bonus` are methods in `SectionDetector` class + - Were being called as `self._method` from `ReferenceAudioListener` instead of `self._section_detector._method` + - Fixed at lines 3247 and 3270-3272 + +After fixes, verification showed: +- Reference audio plan builds correctly with 30+ layers +- `_materialize_reference_audio_layers()` creates actual audio tracks +- Real samples from local library are used (e.g., "mt kick hit 10.wav", "bbh - primer impacto - bass loop 03 g#m.wav") +- 34 audio tracks created (8 derived + 26 base), 0 errors + +## What Currently Works + +The system can currently: + +- generate full projects in Arrangement View +- build MIDI plus stock-device layers +- build many `AUDIO ...` layers from the local library +- analyze a reference track +- build a retrieval-guided original result +- commit scenes to Arrangement +- create buses and returns +- route tracks into buses +- apply track, return and master snapshots +- diagnose the generated set +- validate the generated set +- use DirectML for deeper matching work + +## What Is Stable + +The most stable route today is: + +1. analyze reference if one is available +2. build config in `song_generator.py` +3. materialize Session blueprint through the runtime +4. commit Session to Arrangement +5. place audio layers in Arrangement +6. validate and diagnose + +Do not casually change this order. + +## What Is Still Weak + +The project still has real weaknesses: + +- some generations still feel too loop-based +- retrieval can still pick poor family matches +- remake quality is not yet close enough to a convincing stylistic remake +- some runs still overuse familiar sound families +- loudness and gain staging can vary too much between runs +- derived resample layers are not always present in the final set +- the MCP wrapper for `generate_track` can time out even when Live keeps working + +## Known Operational Problems + +### 1. Recovery popup can block everything + +If Live crashes or thinks recovery is needed, a modal popup can block the socket. + +The file involved is: + +- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\CrashRecoveryInfo.cfg` + +Practical fix used during development: + +- kill Ableton +- blank that file +- restart Ableton + +### 2. MCP wrapper timeout + +The MCP tool wrapper around `generate_track` often times out at about 120 seconds. + +Important: + +- timeout does not always mean generation failed +- often the set keeps building inside Live + +Safer checks after a timeout: + +- `get_session_info()` +- `get_tracks()` +- `validate_set()` +- `diagnose_generated_set()` + +### 3. Runtime state can drift from stored manifest + +At least once, `get_generation_manifest()` returned stale data from an older generation while the actual current set in Live was already different. + +Trust runtime state first: + +- session info +- track list +- diagnosis +- validation + +Manifest is useful, but not always the freshest source. + +### 4. GLM reports often exaggerate completion + +Never trust a GLM report by itself. +Always compare: + +- report +- actual diff +- runtime result + +## Current Validation Habit + +Minimum technical checks after code edits: + +```powershell +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\audio_resampler.py" +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\reference_listener.py" +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\sample_selector.py" +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py" +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py" +python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\__init__.py" +``` + +Minimum Live checks: + +- `get_session_info()` +- `get_tracks()` +- `validate_set(check_clips=True, check_gain=True, check_routing=True)` +- `diagnose_generated_set()` + +Useful direct smoke test: + +```powershell +cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" +python socket_smoke_test.py +``` + +## Current Roadmap Priority + +The active roadmap is: + +1. better library retrieval +2. more real section variation +3. better transition and derived FX placement +4. better device automation +5. gain staging and loudness consistency +6. better remake ability +7. stronger QA and export + +The roadmap source file is: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` + +## Current GLM Workflow + +Keep GLM on short, bounded tasks only. + +Recent task files: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_001_RETRIEVAL_ROLE_PENALTIES.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_002_SECTION_PATTERN_BANK_EXPANSION.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_003_TRANSITION_EVENT_MATERIALIZATION.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_004_DEVICE_AUTOMATION_SNAPSHOTS.md` + +Corresponding reports: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_001_REPORT.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_002_REPORT.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_003_REPORT.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_004_REPORT.md` + +Recommended pattern: + +1. Codex writes one narrow task md +2. GLM edits only 1 to 3 files +3. Codex reviews diff +4. Codex corrects technical and runtime mistakes +5. only then decide whether the task is really done + +## Current Documentation Map + +Core repo docs: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\README.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\CLAUDE.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\AI_HANDOFF.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\ARCHITECTURE.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\MCP_TOOLS.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\REMOTE_PROTOCOL.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\PROJECT_CONTEXT.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\GPU_DIRECTML.md` + +Generator and retrieval docs: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\ABLETUNES_TEMPLATE_NOTES.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\SAMPLE_SYSTEM_README.md` + +Roadmaps and backlog: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\roadmap.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\roadmap2.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` + +Automation detour docs: + +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\README.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\MASTER_AUTONOMOUS_ROADMAP.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\wsl\README.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\wsl\DEPLOYMENT_SUMMARY.md` +- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\WSL_STACK.md` + +## If You Need To Resume Development Safely + +Start here: + +1. read this file +2. read the current roadmap +3. inspect local git status +4. compile changed Python files +5. restart Live if `__init__.py` changed +6. clear recovery popup if needed +7. validate with `get_session_info`, `get_tracks`, `validate_set`, `diagnose_generated_set` +8. only then touch generation logic + +## Current Honest Product Status + +This project is no longer a basic prototype. + +It is already an advanced Ableton generation system that can: + +- create long arrangements +- use references intelligently +- retrieve from a large local library +- mix MIDI and audio material +- build buses, returns and snapshots +- generate editable results in Arrangement View + +But it is still not finished. + +The main gap is no longer raw plumbing. The main gap is artistic fidelity: + +- better retrieval +- better variation +- better remake quality +- more consistent mix quality + +That is the real work that remains. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md new file mode 100644 index 0000000..ff7dcf3 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md @@ -0,0 +1,39 @@ +# Abletunes Template Notes + +Estos templates muestran patrones claros de produccion real que conviene copiar en el generador. + +## Patrones fuertes + +- Son `arrangement-first`, no `session-first`. En los cuatro sets los clips viven casi enteros en Arrangement y las scenes estan vacias o sin rol productivo. +- Todos usan locators para secciones (`Intro`, `Breakdown`, `Drop`, `Break`, `Outro`, `End`) y esas secciones casi siempre caen en bloques de `16`, `32`, `64`, `96` o `128` beats. +- Siempre hay jerarquia por grupos: drums/top drums, bass, instruments, vox, fx. +- Casi siempre existe un `SC Trigger` o pista equivalente dedicada al sidechain. +- Los drums no son una sola pista. Hay capas separadas para kick, clap, snare, hats, ride, perc, fills, crashes, risers y FX. +- Las partes armonicas tampoco son una sola pista. Aparecen capas distintas para bassline, reese/sub, chord, piano, string, pluck, lead y layers. +- Mezclan MIDI e audio de forma agresiva. Un productor no se queda solo con MIDI: imprime loops, resamples, freeze y audios procesados cuando hace falta. +- Hay bastante tratamiento por pista: `Eq8`, `Compressor2`, `Reverb`, `AutoFilter`, `PingPongDelay`, `GlueCompressor`, `MultibandDynamics`, `Limiter`, `Saturator`. + +## Lo que mas importa para el MCP + +- El generador no tiene que crear "un loop largo". Tiene que crear secciones con mutaciones claras entre una y otra. +- Cada seccion necesita variacion de densidad, no solo mute/unmute basico. Los templates meten fills, crashes, reverse FX, chants, top loops y capas extra solo en puntos de tension. +- El arreglo profesional usa mas pistas especializadas de las que hoy genera el MCP. La separacion por rol es parte del sonido. +- Hay que imprimir mas audio original derivado del propio proyecto: resamples, reverses, freezes y FX hechos a partir de material propio. +- Los returns son pocos pero concretos. No hace falta llenar de sends; hace falta `reverb`, `delay` y buses de grupo bien usados. + +## Señales concretas vistas en el pack + +- `Abletunes - Dope As F_ck`: `128 BPM`, 6 grupos, 2 returns, `Sylenth1` dominante, mucha automatizacion (`8121` eventos). +- `Abletunes - Freedom`: `126 BPM`, mezcla house mas simple, bateria muy separada, menos automatizacion, mucho `OriginalSimpler` + `Serum`. +- `Abletunes - Hideout`: set largo y cargado, `Massive` + `Sylenth1`, una bateria enorme y mucha automatizacion (`6470` eventos). +- `Abletunes - Nobody's Watching`: enfoque mas stock, usa `Operator`, `Simpler`, bastante audio vocal y FX impresos. + +## Reglas que deberiamos incorporar + +- Generar por defecto en Arrangement, con locators reales y secciones de 16/32 bars. +- Añadir `SC Trigger`, grupos y returns fijos desde el blueprint. +- Separar drums en mas roles: kick, clap main, clap layer, snare fill, hats, ride, perc main, perc FX, crash, reverse, riser. +- Separar armonia y hooks: sub, bassline, chord stab, piano/keys, string/pad, pluck, lead, accent synth. +- Crear eventos de transicion por seccion: uplifter, downlifter, reverse crash, vocal chop, tom fill. +- Imprimir audio derivado del material generado cuando una capa necesite mas impacto o textura. +- Meter automatizacion por seccion en filtros, sends, volumen de grupos y FX de transicion. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md new file mode 100644 index 0000000..9d6835c --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md @@ -0,0 +1,203 @@ +# Sistema de Gestión de Samples - AbletonMCP-AI + +Sistema completo de indexación, clasificación y selección inteligente de samples musicales. + +## Componentes + +### 1. `audio_analyzer.py` - Análisis de Audio + +Detecta automáticamente características de archivos de audio: +- **BPM**: Detección de tempo mediante análisis de onset +- **Key**: Detección de tonalidad mediante cromagrama +- **Tipo**: Clasificación en kick, snare, bass, synth, etc. +- **Características espectrales**: Centroide, rolloff, RMS + +**Uso básico:** +```python +from audio_analyzer import analyze_sample + +result = analyze_sample("path/to/sample.wav") +print(f"BPM: {result['bpm']}, Key: {result['key']}") +print(f"Tipo: {result['sample_type']}") +``` + +**Backends:** +- `librosa`: Análisis completo (requiere instalación) +- `basic`: Análisis por nombre de archivo (sin dependencias) + +### 2. `sample_manager.py` - Gestión de Librería + +Gestor completo de la librería de samples: +- Indexación recursiva de directorios +- Clasificación automática por categorías +- Metadatos extensibles (tags, rating, géneros) +- Búsqueda avanzada con múltiples filtros +- Persistencia en JSON + +**Categorías principales:** +- `drums`: kick, snare, clap, hat, perc, shaker, tom, cymbal +- `bass`: sub, bassline, acid +- `synths`: lead, pad, pluck, chord, fx +- `vocals`: vocal, speech, chant +- `loops`: drum_loop, bass_loop, synth_loop, full_loop +- `one_shots`: hit, noise + +**Uso básico:** +```python +from sample_manager import SampleManager + +# Inicializar +manager = SampleManager(r"C:\Users\ren\embeddings\all_tracks") + +# Escanear +stats = manager.scan_directory(analyze_audio=True) + +# Buscar +kicks = manager.search(sample_type="kick", key="Am", bpm=128) +house_samples = manager.search(genres=["house"], limit=10) + +# Obtener pack completo +pack = manager.get_pack_for_genre("techno", key="F#m", bpm=130) +``` + +### 3. `sample_selector.py` - Selección Inteligente + +Selección contextual basada en género, key y BPM: +- Perfiles de género predefinidos +- Matching armónico entre samples +- Generación de kits de batería coherentes +- Mapeo MIDI automático + +**Géneros soportados:** +- Techno (industrial, minimal, acid) +- House (deep, classic, progressive) +- Tech-House +- Trance (progressive, psy) +- Drum & Bass (liquid, neuro) +- Ambient + +**Uso básico:** +```python +from sample_selector import SampleSelector + +selector = SampleSelector() + +# Seleccionar para un género +group = selector.select_for_genre("techno", key="F#m", bpm=130) + +# Acceder a elementos +group.drums.kick # Sample de kick +group.bass # Lista de bass samples +group.synths # Lista de synths + +# Mapeo MIDI +mapping = selector.get_midi_mapping_for_kit(group.drums) + +# Cambio de key armónico +new_key = selector.suggest_key_change("Am", "fifth_up") # Em +``` + +## Integración con MCP Server + +El servidor MCP expone las siguientes herramientas: + +### Gestión de Librería +- `scan_sample_library` - Escanear directorio de samples +- `get_sample_library_stats` - Estadísticas de la librería + +### Búsqueda y Selección +- `advanced_search_samples` - Búsqueda con filtros múltiples +- `select_samples_for_genre` - Selección automática por género +- `get_drum_kit_mapping` - Kit de batería con mapeo MIDI +- `get_sample_pack_for_project` - Pack completo para proyecto + +### Análisis y Compatibilidad +- `analyze_audio_file` - Analizar archivo de audio +- `find_compatible_samples` - Encontrar samples compatibles +- `suggest_key_change` - Sugerir cambios de tonalidad + +## Estructura de Datos + +### Sample +```python +@dataclass +class Sample: + id: str # ID único + name: str # Nombre del archivo + path: str # Ruta completa + category: str # Categoría principal + subcategory: str # Subcategoría + sample_type: str # Tipo específico + key: Optional[str] # Tonalidad (Am, F#m, C) + bpm: Optional[float] # BPM + duration: float # Duración en segundos + genres: List[str] # Géneros asociados + tags: List[str] # Tags + rating: int # Rating 0-5 +``` + +### DrumKit +```python +@dataclass +class DrumKit: + name: str + kick: Optional[Sample] + snare: Optional[Sample] + clap: Optional[Sample] + hat_closed: Optional[Sample] + hat_open: Optional[Sample] + perc1: Optional[Sample] + perc2: Optional[Sample] +``` + +## Mapeo MIDI + +Notas estándar para drums: +- `36` (C1): Kick +- `38` (D1): Snare +- `39` (D#1): Clap +- `42` (F#1): Closed Hat +- `46` (A#1): Open Hat +- `41` (F1): Tom Low +- `49` (C#2): Crash + +## Ejemplos de Uso + +### Crear un track completo +```python +# Seleccionar samples para techno +selector = get_selector() +group = selector.select_for_genre("techno", key="F#m", bpm=130) + +# Usar con Ableton +ableton = get_ableton_connection() + +# Crear tracks y cargar samples +for i, sample in enumerate([group.drums.kick, group.drums.snare]): + if sample: + print(f"Cargar {sample.name} en track {i}") +``` + +### Buscar samples compatibles +```python +# Encontrar samples que combinen con un kick +kick = manager.get_by_path("path/to/kick.wav") +compatible = selector.find_compatible_samples(kick, max_results=5) + +for sample, score in compatible: + print(f"{sample.name}: {score:.1%} compatible") +``` + +## Archivos Generados + +- `.sample_cache/sample_library.json` - Índice de la librería +- `.sample_cache/library_stats.json` - Estadísticas + +## Dependencias Opcionales + +Para análisis de audio completo: +```bash +pip install librosa soundfile numpy +``` + +Sin estas dependencias, el sistema funciona en modo "basic" usando metadatos de los nombres de archivo. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py new file mode 100644 index 0000000..aef464d --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py @@ -0,0 +1,26 @@ +""" +MCP Server para AbletonMCP-AI +Servidor FastMCP que conecta Claude con Ableton Live 12 +""" + +from .server import mcp, main +from .song_generator import SongGenerator +from .sample_index import SampleIndex + +# Nuevo sistema de samples +try: + SAMPLE_SYSTEM_AVAILABLE = True +except ImportError: + SAMPLE_SYSTEM_AVAILABLE = False + +__all__ = [ + 'mcp', 'main', + 'SongGenerator', 'SampleIndex', +] + +if SAMPLE_SYSTEM_AVAILABLE: + __all__.extend([ + 'SampleManager', 'Sample', 'get_manager', + 'SampleSelector', 'get_selector', 'DrumKit', 'InstrumentGroup', + 'AudioAnalyzer', 'analyze_sample', 'SampleType', + ]) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py new file mode 100644 index 0000000..470f44a --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py @@ -0,0 +1,318 @@ +import json +import socket +from datetime import datetime +import os + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent11_review_harmony.txt" + +CHORD_TONES = { + "Am": [57, 60, 64], + "F": [53, 57, 60], + "C": [48, 52, 55], + "G": [43, 47, 50] +} + +CHORD_NAMES = { + "Am": ["A", "C", "E"], + "F": ["F", "A", "C"], + "C": ["C", "E", "G"], + "G": ["G", "B", "D"] +} + +AM_SCALE = [57, 59, 60, 62, 64, 65, 67] + +PROGRESSION_ORDER = ["Am", "F", "C", "G"] +CHORD_DURATION = 8.0 + +def pitch_to_name(pitch): + names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + return names[pitch % 12] + +def get_chord_at_time(start_time): + chord_index = int(start_time // CHORD_DURATION) % 4 + return PROGRESSION_ORDER[chord_index] + +def normalize_to_octave(pitch, target_octave=3): + return (pitch % 12) + (target_octave * 12) + +class AbletonSocketClient: + def __init__(self, host="127.0.0.1", port=9877, timeout=15.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type, params=None): + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + + return json.loads(line) + +def log_message(msg): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_line = f"[{timestamp}] {msg}\n" + print(log_line.strip()) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_line) + +def analyze_track_harmony(client, track_index, track_name, scene_index=0): + issues = [] + notes_in_key = 0 + notes_out_of_key = 0 + chord_matches = 0 + chord_mismatches = 0 + + try: + response = client.send("get_notes", { + "track_index": track_index, + "scene_index": scene_index + }) + + if response.get("status") != "success": + return {"error": response.get("message", "Unknown error")} + + notes = response.get("result", {}).get("notes", []) + + if not notes: + return {"warning": "No notes found in clip"} + + for note in notes: + pitch = note.get("pitch", 60) + start = note.get("start", 0) + duration = note.get("duration", 1) + + pitch_class = pitch % 12 + current_chord = get_chord_at_time(start) + + in_am_scale = any((pitch % 12) == (p % 12) for p in AM_SCALE) + + if in_am_scale: + notes_in_key += 1 + else: + notes_out_of_key += 1 + issues.append({ + "type": "out_of_key", + "pitch": pitch, + "pitch_name": pitch_to_name(pitch), + "start": start, + "expected": "Am scale (A, B, C, D, E, F, G)" + }) + + chord_tones_normalized = [t % 12 for t in CHORD_TONES[current_chord]] + if pitch_class in chord_tones_normalized: + chord_matches += 1 + else: + chord_mismatches += 1 + chord_tone_names = CHORD_NAMES[current_chord] + issues.append({ + "type": "chord_tone_mismatch", + "pitch": pitch, + "pitch_name": pitch_to_name(pitch), + "start": start, + "chord": current_chord, + "expected_chord_tones": chord_tone_names + }) + + return { + "total_notes": len(notes), + "notes_in_key": notes_in_key, + "notes_out_of_key": notes_out_of_key, + "chord_matches": chord_matches, + "chord_mismatches": chord_mismatches, + "issues": issues + } + + except Exception as e: + return {"error": str(e)} + +def analyze_bass_notes(client, track_index, scene_index=0): + issues = [] + correct_roots = 0 + incorrect_roots = 0 + + try: + response = client.send("get_notes", { + "track_index": track_index, + "scene_index": scene_index + }) + + if response.get("status") != "success": + return {"error": response.get("message", "Unknown error")} + + notes = response.get("result", {}).get("notes", []) + + if not notes: + return {"warning": "No bass notes found"} + + ROOT_NOTES = { + "Am": 57, + "F": 53, + "C": 48, + "G": 43 + } + + for note in notes: + pitch = note.get("pitch", 60) + start = note.get("start", 0) + + current_chord = get_chord_at_time(start) + expected_root = ROOT_NOTES[current_chord] + expected_root_class = expected_root % 12 + pitch_class = pitch % 12 + + if pitch_class == expected_root_class: + correct_roots += 1 + else: + incorrect_roots += 1 + if start % 4.0 < 0.5: + issues.append({ + "type": "wrong_bass_root", + "pitch": pitch, + "pitch_name": pitch_to_name(pitch), + "start": start, + "chord": current_chord, + "expected_root": pitch_to_name(expected_root) + }) + + return { + "total_notes": len(notes), + "correct_roots": correct_roots, + "incorrect_roots": incorrect_roots, + "issues": issues + } + + except Exception as e: + return {"error": str(e)} + +def main(): + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + + log_message("=" * 60) + log_message("AGENT 11 - HARMONIC COHERENCE REVIEW") + log_message("=" * 60) + log_message(f"Target progression: Am - F - C - G (8 beats each)") + log_message(f"Am scale: A, B, C, D, E, F, G") + log_message("") + + client = AbletonSocketClient() + + session = client.send("get_session_info") + if session.get("status") != "success": + log_message("ERROR: Cannot connect to Ableton session") + return + + log_message(f"Session: {session.get('result', {}).get('num_tracks', 0)} tracks, " + f"tempo: {session.get('result', {}).get('tempo', 120)} BPM") + + tracks_response = client.send("get_tracks") + if tracks_response.get("status") != "success": + log_message("ERROR: Cannot get tracks") + return + + tracks = tracks_response.get("result", []) + + midi_tracks = [ + (i, t.get("name", "Unknown"), t.get("session_clip_count", 0)) + for i, t in enumerate(tracks) + if t.get("has_midi_input") and t.get("session_clip_count", 0) > 0 + ] + + log_message(f"Found {len(midi_tracks)} MIDI tracks with clips") + log_message("") + + total_issues = 0 + critical_issues = 0 + + for track_index, track_name, clip_count in midi_tracks: + log_message(f"\n--- TRACK {track_index}: {track_name} ---") + + if "BASS" in track_name.upper(): + log_message("Analyzing as BASS track (checking root notes)") + result = analyze_bass_notes(client, track_index) + else: + log_message("Analyzing harmonic content") + result = analyze_track_harmony(client, track_index, track_name) + + if "error" in result: + log_message(f" ERROR: {result['error']}") + continue + + if "warning" in result: + log_message(f" WARNING: {result['warning']}") + continue + + if "total_notes" in result: + log_message(f" Total notes: {result['total_notes']}") + + if "notes_in_key" in result: + log_message(f" Notes in Am scale: {result['notes_in_key']}/{result['total_notes']}") + if result["notes_out_of_key"] > 0: + log_message(f" OUT OF KEY: {result['notes_out_of_key']} notes") + total_issues += result["notes_out_of_key"] + + if "chord_matches" in result: + log_message(f" Chord tone matches: {result['chord_matches']}/{result['total_notes']}") + if result["chord_mismatches"] > 0: + log_message(f" CHORD MISMATCHES: {result['chord_mismatches']} notes") + + if "correct_roots" in result: + log_message(f" Correct bass roots: {result['correct_roots']}/{result['total_notes']}") + if result["incorrect_roots"] > 0: + log_message(f" WRONG BASS ROOTS: {result['incorrect_roots']} notes") + total_issues += result["incorrect_roots"] + critical_issues += result["incorrect_roots"] + + if result.get("issues"): + for issue in result["issues"][:5]: + if issue["type"] == "out_of_key": + log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " + f"not in Am scale") + elif issue["type"] == "chord_tone_mismatch": + log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " + f"not in chord {issue['chord']} (expected: {issue['expected_chord_tones']})") + elif issue["type"] == "wrong_bass_root": + log_message(f" [CRITICAL] Bass note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " + f"should be {issue['expected_root']} for chord {issue['chord']}") + + log_message("\n" + "=" * 60) + log_message("HARMONIC COHERENCE SUMMARY") + log_message("=" * 60) + + if critical_issues > 0: + log_message(f"STATUS: CRITICAL ISSUES FOUND") + log_message(f" - {critical_issues} critical bass root mismatches") + log_message(f" - {total_issues} total harmonic issues") + log_message("") + log_message("RECOMMENDATION: Review bass notes and chord tones") + elif total_issues > 0: + log_message(f"STATUS: MINOR ISSUES FOUND") + log_message(f" - {total_issues} notes out of Am scale") + log_message("") + log_message("RECOMMENDATION: May be intentional chromatic passing tones") + else: + log_message(f"STATUS: HARMONICALLY COHERENT") + log_message(f" - All notes in Am scale") + log_message(f" - Bass follows root progression A-F-C-G") + log_message(f" - Chord tones align with progression") + + log_message("") + log_message("Agent 11 review complete.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py new file mode 100644 index 0000000..ad8fec6 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py @@ -0,0 +1,192 @@ +""" +Agent 17 - Sample Loading Reviewer +Verifies audio tracks have samples loaded and loads samples if needed. +""" +import socket +import json +import os +import glob +from datetime import datetime + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent17_review_samples.txt" +SAMPLE_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks" +ORGANIZED_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples" +HOST = "127.0.0.1" +PORT = 9877 + +def log(message): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_line = f"[{timestamp}] {message}" + print(log_line) + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_line + "\n") + +def send_command(command_type, params=None): + if params is None: + params = {} + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + try: + sock.connect((HOST, PORT)) + request = {"type": command_type, "params": params} + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + response = b"" + while True: + chunk = sock.recv(4096) + if not chunk: + break + response += chunk + if b"\n" in response: + break + return json.loads(response.decode("utf-8").strip()) + finally: + sock.close() + +def find_samples(query, sample_type=None): + samples = [] + search_paths = [ORGANIZED_LIBRARY, SAMPLE_LIBRARY] + + for search_path in search_paths: + if not os.path.exists(search_path): + continue + + pattern = f"**/*{query}*.wav" + for filepath in glob.glob(os.path.join(search_path, pattern), recursive=True): + if sample_type: + type_dir = os.path.join(search_path, sample_type) + if type_dir.lower() in filepath.lower(): + samples.append(filepath) + else: + samples.append(filepath) + + return samples[:15] + +def load_samples_to_track(track_index, track_name, sample_type, positions): + samples = find_samples(sample_type) + if not samples: + log(f" No samples found for type: {sample_type}") + return 0 + + clips_loaded = 0 + for i, sample_path in enumerate(samples): + if clips_loaded >= 10: + break + + position = positions[i] if i < len(positions) else positions[-1] + (i - len(positions) + 1) * 4 + + try: + result = send_command("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": sample_path, + "positions": [position], + "name": f"{track_name} Clip {i+1}" + }) + if result.get("status") == "success": + clips_loaded += 1 + log(f" Loaded: {os.path.basename(sample_path)} at position {position}") + else: + log(f" Failed: {result.get('message', 'Unknown error')}") + except Exception as e: + log(f" Error loading sample: {e}") + + return clips_loaded + +def main(): + log("=" * 60) + log("Agent 17 - Sample Loading Reviewer Started") + log("=" * 60) + + log("\n[1] Connecting to Ableton socket...") + try: + session = send_command("get_session_info", {}) + if session.get("status") != "success": + log(f"ERROR: Failed to get session info: {session}") + return + log(f"Connected. Tempo: {session.get('result', {}).get('tempo', 'unknown')} BPM") + except Exception as e: + log(f"ERROR: Cannot connect to Ableton: {e}") + return + + log("\n[2] Getting track list...") + try: + tracks_response = send_command("get_tracks", {}) + if tracks_response.get("status") != "success": + log(f"ERROR: Failed to get tracks: {tracks_response}") + return + tracks = tracks_response.get("result", []) + log(f"Found {len(tracks)} tracks") + except Exception as e: + log(f"ERROR: Cannot get tracks: {e}") + return + + log("\n[3] Analyzing audio tracks...") + audio_tracks_needing_samples = [] + + for track in tracks: + track_name = track.get("name", "") + track_index = track.get("index", -1) + has_audio = track.get("has_audio_input", False) and track.get("has_audio_output", False) + has_midi = track.get("has_midi_input", False) + arr_clips = track.get("arrangement_clip_count", 0) + + if has_audio and not has_midi: + if arr_clips < 10: + audio_tracks_needing_samples.append({ + "index": track_index, + "name": track_name, + "clips": arr_clips + }) + log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (NEEDS SAMPLES)") + else: + log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (OK)") + + if not audio_tracks_needing_samples: + log("\n[4] All audio tracks have sufficient samples!") + return + + log(f"\n[4] {len(audio_tracks_needing_samples)} tracks need samples. Loading...") + + track_type_map = { + "KICK": "kick", + "SNARE": "snare", + "HATS": "hat", + "HAT": "hat", + "BASS": "bass", + "LEAD": "synth", + "PAD": "atmos", + "ARP": "synth", + "PERC": "percussion", + "VOCAL": "vocal", + "RISER": "riser", + "CRASH": "crash", + "DOWNLIFTER": "fx", + "AUDIO": "synth" + } + + positions = [0, 8, 16, 24, 32, 40, 48, 56, 64, 72] + + for track_info in audio_tracks_needing_samples: + track_index = track_info["index"] + track_name = track_info["name"] + + sample_type = "synth" + for key, stype in track_type_map.items(): + if key in track_name.upper(): + sample_type = stype + break + + log(f"\n Loading {sample_type} samples into track {track_index} ('{track_name}')...") + clips_loaded = load_samples_to_track(track_index, track_name, sample_type, positions) + track_info["loaded"] = clips_loaded + + log("\n" + "=" * 60) + log("SUMMARY") + log("=" * 60) + for track_info in audio_tracks_needing_samples: + log(f" Track {track_info['index']} ('{track_info['name']}'): {track_info.get('clips', 0)} -> +{track_info.get('loaded', 0)} clips loaded") + + log("\nAgent 17 completed.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py new file mode 100644 index 0000000..5d1e4fe --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Agent 7 - VOCAL/CHOIR SPECIALIST +Loads vocal samples at specific arrangement positions +""" +import socket +import json +import sys + +HOST = "127.0.0.1" +PORT = 9877 + +VOCAL_MAIN_TRACK = 12 +VOCAL_TEXTURE_TRACK = 13 + +VOCAL_MAIN_SAMPLES = [ + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\BBH- Primer Impacto - Vocal Quema D#m 126 Bpm.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 1.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 2.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 3.wav", +] + +VOCAL_TEXTURE_SAMPLES = [ + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_03_Am_125.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_05_Cm_125.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_08_Cm_125.wav", + r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_10_Bm_125.wav", +] + +VOCAL_MAIN_POSITIONS = [16.0, 48.0, 80.0, 112.0] +VOCAL_TEXTURE_POSITIONS = [0.0, 32.0, 64.0, 96.0] + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent7_vocals.txt" + +def send_command(command_type: str, params: dict = None, timeout: float = 45.0) -> dict: + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((HOST, PORT), timeout=timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + line = reader.readline() + if not line: + raise RuntimeError(f"No response for command: {command_type}") + return json.loads(line) + +def log(msg: str): + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(msg + "\n") + print(msg) + +def main(): + log("=" * 60) + log("AGENT 7 - VOCAL/CHOIR SPECIALIST") + log("=" * 60) + + # Step 1: Set input routing to "No Input" for both tracks + log("\n[STEP 1] Setting input routing to 'No Input'...") + + for track_idx, track_name in [(VOCAL_MAIN_TRACK, "VOCAL MAIN"), (VOCAL_TEXTURE_TRACK, "VOCAL TEXTURE")]: + try: + result = send_command("set_track_input_routing", {"index": track_idx, "routing_name": "No Input"}) + log(f" Track {track_idx} ({track_name}): {result}") + except Exception as e: + log(f" ERROR Track {track_idx}: {e}") + + # Step 2: Load VOCAL MAIN samples at key moments + log("\n[STEP 2] Loading VOCAL MAIN samples at key moments...") + + for i, (sample_path, position) in enumerate(zip(VOCAL_MAIN_SAMPLES, VOCAL_MAIN_POSITIONS)): + try: + result = send_command("create_arrangement_audio_pattern", { + "track_index": VOCAL_MAIN_TRACK, + "file_path": sample_path, + "positions": [position], + "name": f"Vocal Main {i+1}" + }) + log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}") + except Exception as e: + log(f" ERROR at position {position}: {e}") + + # Step 3: Load VOCAL TEXTURE samples at atmospheric positions + log("\n[STEP 3] Loading VOCAL TEXTURE samples at atmospheric positions...") + + for i, (sample_path, position) in enumerate(zip(VOCAL_TEXTURE_SAMPLES, VOCAL_TEXTURE_POSITIONS)): + try: + result = send_command("create_arrangement_audio_pattern", { + "track_index": VOCAL_TEXTURE_TRACK, + "file_path": sample_path, + "positions": [position], + "name": f"Vocal Texture {i+1}" + }) + log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}") + except Exception as e: + log(f" ERROR at position {position}: {e}") + + log("\n" + "=" * 60) + log("AGENT 7 COMPLETE - Vocal layers loaded") + log("=" * 60) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py new file mode 100644 index 0000000..b5c7561 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py @@ -0,0 +1,102 @@ +import json +import socket +from datetime import datetime + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent8_fx.txt" + +def log(msg): + timestamp = datetime.now().isoformat() + entry = f"[{timestamp}] {msg}" + print(entry) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(entry + "\n") + +class AbletonSocketClient: + def __init__(self, host="127.0.0.1", port=9877, timeout=30.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type, params=None): + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + return json.loads(line) + +def main(): + log("=" * 60) + log("AGENT 8 - FX TRANSITION SPECIALIST") + log("=" * 60) + + client = AbletonSocketClient() + + RISER_TRACK = 16 + DOWNLIFTER_TRACK = 17 + CRASH_TRACK = 18 + + RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto -Risers 2.wav" + DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\EFX_01_Em_125.wav" + CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto - Crash 1.wav" + + RISER_POSITIONS = [14, 46, 78, 110, 142, 174] + DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176] + CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192] + + log(f"Track indices: RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}") + log(f"Riser positions: {RISER_POSITIONS}") + log(f"Downlifter positions: {DOWNLIFTER_POSITIONS}") + log(f"Crash positions: {CRASH_POSITIONS}") + + log("") + log("Step 1: Placing RISER samples...") + result = client.send("create_arrangement_audio_pattern", { + "track_index": RISER_TRACK, + "file_path": RISER_PATH, + "positions": RISER_POSITIONS, + "name": "RISER FX" + }) + log(f"RISER result: {json.dumps(result, indent=2)}") + + log("") + log("Step 2: Placing DOWNLIFTER samples (using EFX fallback)...") + result = client.send("create_arrangement_audio_pattern", { + "track_index": DOWNLIFTER_TRACK, + "file_path": DOWNLIFTER_PATH, + "positions": DOWNLIFTER_POSITIONS, + "name": "DOWNLIFTER FX" + }) + log(f"DOWNLIFTER result: {json.dumps(result, indent=2)}") + + log("") + log("Step 3: Placing CRASH samples...") + result = client.send("create_arrangement_audio_pattern", { + "track_index": CRASH_TRACK, + "file_path": CRASH_PATH, + "positions": CRASH_POSITIONS, + "name": "CRASH FX" + }) + log(f"CRASH result: {json.dumps(result, indent=2)}") + + log("") + log("=" * 60) + log("AGENT 8 COMPLETE") + log("=" * 60) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py new file mode 100644 index 0000000..bbbfce8 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py @@ -0,0 +1,184 @@ +""" +Agent 9 - PERCUSSION SPECIALIST +Loads percussion samples into AUDIO PERC MAIN and AUDIO PERC FX tracks. +""" +import json +import socket +import os +from datetime import datetime +from typing import Any, Dict, List + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent9_perc.txt" +HOST = "127.0.0.1" +PORT = 9877 +TIMEOUT = 30.0 + +PERC_MAIN_TRACK_INDEX = 14 +PERC_FX_TRACK_INDEX = 15 + +PERC_MAIN_POSITIONS = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176] +PERC_FX_POSITIONS = [4, 12, 20, 28, 36, 44, 52, 60] + +SAMPLE_BASE = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples" + +PERC_LOOP_SAMPLES = [ + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_01_Fm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_02_Any_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_03_A#_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_04_Any_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_05_Any_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_06_Dm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_07_Cm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_08_Fm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_09_Bm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_10_Dm_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_11_Am_125.wav"), + os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_12_Bm_125.wav"), +] + +PERC_FX_SAMPLES = [ + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 2.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 3.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 1.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 2.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 3.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 4.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 6.wav"), + os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 8.wav"), +] + + +def log(msg: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{timestamp}] {msg}" + print(line) + try: + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception as e: + print(f"Log write error: {e}") + + +class AbletonSocketClient: + def __init__(self, host: str = HOST, port: int = PORT, timeout: float = TIMEOUT): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + + return json.loads(line) + + +def set_input_routing(client: AbletonSocketClient, track_index: int, routing_name: str) -> bool: + try: + response = client.send("set_track_input_routing", { + "track_index": track_index, + "routing_name": routing_name, + }) + if response.get("status") == "success": + log(f"Set track {track_index} input routing to '{routing_name}'") + return True + else: + log(f"Failed to set input routing: {response.get('message', 'unknown error')}") + return False + except Exception as e: + log(f"Error setting input routing: {e}") + return False + + +def load_audio_pattern(client: AbletonSocketClient, track_index: int, file_path: str, positions: List[float], name: str = "") -> bool: + if not os.path.exists(file_path): + log(f"Sample not found: {file_path}") + return False + + try: + response = client.send("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": file_path, + "positions": positions, + "name": name or os.path.basename(file_path), + }) + if response.get("status") == "success": + log(f"Loaded '{os.path.basename(file_path)}' at positions {positions[:3]}... on track {track_index}") + return True + else: + log(f"Failed to load audio: {response.get('message', 'unknown error')}") + return False + except Exception as e: + log(f"Error loading audio: {e}") + return False + + +def main(): + log("=" * 60) + log("AGENT 9 - PERCUSSION SPECIALIST STARTING") + log("=" * 60) + + client = AbletonSocketClient() + + log("Connecting to Ableton socket...") + try: + info = client.send("get_session_info", {}) + if info.get("status") != "success": + log("Failed to get session info") + return + log(f"Connected. BPM: {info.get('result', {}).get('tempo', 'unknown')}") + except Exception as e: + log(f"Connection failed: {e}") + return + + log("Setting input routing to 'No Input'...") + set_input_routing(client, PERC_MAIN_TRACK_INDEX, "No Input") + set_input_routing(client, PERC_FX_TRACK_INDEX, "No Input") + + log("") + log("Loading PERC MAIN loops...") + main_loaded = 0 + for i, pos in enumerate(PERC_MAIN_POSITIONS): + if i < len(PERC_LOOP_SAMPLES): + sample = PERC_LOOP_SAMPLES[i] + if load_audio_pattern(client, PERC_MAIN_TRACK_INDEX, sample, [float(pos)], f"PERC_LOOP_{i+1}"): + main_loaded += 1 + + log(f"PERC MAIN: {main_loaded}/{len(PERC_MAIN_POSITIONS)} samples loaded") + + log("") + log("Loading PERC FX hits...") + fx_loaded = 0 + for i, pos in enumerate(PERC_FX_POSITIONS): + if i < len(PERC_FX_SAMPLES): + sample = PERC_FX_SAMPLES[i] + if load_audio_pattern(client, PERC_FX_TRACK_INDEX, sample, [float(pos)], f"PERC_FX_{i+1}"): + fx_loaded += 1 + + log(f"PERC FX: {fx_loaded}/{len(PERC_FX_POSITIONS)} samples loaded") + + log("") + log("=" * 60) + log(f"AGENT 9 COMPLETE: MAIN={main_loaded}, FX={fx_loaded}") + log("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py new file mode 100644 index 0000000..29feefa --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py @@ -0,0 +1,681 @@ +""" +audio_analyzer.py - Análisis de audio para detección de Key y BPM + +Proporciona análisis básico de archivos de audio para extraer: +- BPM (tempo) mediante detección de onset y autocorrelación +- Key (tonalidad) mediante análisis de cromagrama +- Características espectrales para clasificación +""" + +import os +import logging +import numpy as np +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, List +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger("AudioAnalyzer") + +# Constantes musicales +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] +KEY_PROFILES = { + # Perfiles de Krumhansl-Schmuckler para detección de tonalidad + 'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], + 'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17] +} + +CIRCLE_OF_FIFTHS_MAJOR = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F'] +CIRCLE_OF_FIFTHS_MINOR = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm'] + + +class SampleType(Enum): + """Tipos de samples musicales""" + KICK = "kick" + SNARE = "snare" + CLAP = "clap" + HAT_CLOSED = "hat_closed" + HAT_OPEN = "hat_open" + HAT = "hat" + PERC = "perc" + SHAKER = "shaker" + TOM = "tom" + CRASH = "crash" + RIDE = "ride" + BASS = "bass" + SYNTH = "synth" + PAD = "pad" + LEAD = "lead" + PLUCK = "pluck" + ARP = "arp" + CHORD = "chord" + STAB = "stab" + VOCAL = "vocal" + FX = "fx" + LOOP = "loop" + AMBIENCE = "ambience" + UNKNOWN = "unknown" + + +@dataclass +class AudioFeatures: + """Características extraídas de un archivo de audio""" + bpm: Optional[float] + key: Optional[str] + key_confidence: float + duration: float + sample_rate: int + sample_type: SampleType + spectral_centroid: float + spectral_rolloff: float + zero_crossing_rate: float + rms_energy: float + is_harmonic: bool + is_percussive: bool + suggested_genres: List[str] + + +class AudioAnalyzer: + """ + Analizador de audio para samples musicales. + + Soporta múltiples backends: + - librosa (recomendado, más preciso) + - basic (fallback sin dependencias externas, basado en nombre de archivo) + """ + + def __init__(self, backend: str = "auto"): + """ + Inicializa el analizador de audio. + + Args: + backend: 'librosa', 'basic', o 'auto' (detecta automáticamente) + """ + self.backend = backend + self._librosa_available = False + self._soundfile_available = False + + if backend in ("auto", "librosa"): + self._check_librosa() + + if self._librosa_available: + logger.info("Usando backend: librosa") + else: + logger.info("Usando backend: basic (análisis por nombre de archivo)") + + def _check_librosa(self): + """Verifica si librosa está disponible""" + try: + import librosa + import soundfile as sf + self._librosa_available = True + self._soundfile_available = True + self.librosa = librosa + self.sf = sf + except ImportError: + self._librosa_available = False + self._soundfile_available = False + + def analyze(self, file_path: str) -> AudioFeatures: + """ + Analiza un archivo de audio y extrae características. + + Args: + file_path: Ruta al archivo de audio + + Returns: + AudioFeatures con los datos extraídos + """ + path = Path(file_path) + + if not path.exists(): + raise FileNotFoundError(f"Archivo no encontrado: {file_path}") + + # Intentar análisis con librosa si está disponible + if self._librosa_available: + try: + return self._analyze_with_librosa(file_path) + except Exception as e: + logger.warning(f"Error con librosa: {e}, usando análisis básico") + + # Fallback a análisis básico + return self._analyze_basic(file_path) + + def _analyze_with_librosa(self, file_path: str) -> AudioFeatures: + """Análisis completo usando librosa""" + # Cargar audio + y, sr = self.librosa.load(file_path, sr=None, mono=True) + + # Duración + duration = self.librosa.get_duration(y=y, sr=sr) + + # Detectar BPM + tempo, _ = self.librosa.beat.beat_track(y=y, sr=sr) + bpm = float(tempo) if isinstance(tempo, (int, float, np.number)) else None + + # Análisis espectral + spectral_centroids = self.librosa.feature.spectral_centroid(y=y, sr=sr)[0] + spectral_rolloffs = self.librosa.feature.spectral_rolloff(y=y, sr=sr)[0] + zcr = self.librosa.feature.zero_crossing_rate(y)[0] + rms = self.librosa.feature.rms(y=y)[0] + + # Detectar key + key, key_confidence = self._detect_key_librosa(y, sr) + + # Clasificación percusivo vs armónico + is_percussive = self._is_percussive(y, sr) + is_harmonic = not is_percussive and duration > 1.0 + + # Determinar tipo de sample + sample_type = self._classify_sample_type( + file_path, is_percussive, is_harmonic, duration, + float(np.mean(spectral_centroids)), float(np.mean(rms)) + ) + + # Sugerir géneros + suggested_genres = self._suggest_genres(sample_type, bpm, key) + + return AudioFeatures( + bpm=bpm, + key=key, + key_confidence=key_confidence, + duration=duration, + sample_rate=sr, + sample_type=sample_type, + spectral_centroid=float(np.mean(spectral_centroids)), + spectral_rolloff=float(np.mean(spectral_rolloffs)), + zero_crossing_rate=float(np.mean(zcr)), + rms_energy=float(np.mean(rms)), + is_harmonic=is_harmonic, + is_percussive=is_percussive, + suggested_genres=suggested_genres + ) + + def _detect_key_librosa(self, y: np.ndarray, sr: int) -> Tuple[Optional[str], float]: + """ + Detecta la tonalidad usando cromagrama y correlación con perfiles. + """ + try: + # Calcular cromagrama + chroma = self.librosa.feature.chroma_stft(y=y, sr=sr) + chroma_avg = np.mean(chroma, axis=1) + + # Normalizar + chroma_avg = chroma_avg / (np.sum(chroma_avg) + 1e-10) + + best_key = None + best_score = -np.inf + best_mode = None + + # Probar todas las tonalidades mayores y menores + for mode, profile in KEY_PROFILES.items(): + for i in range(12): + # Rotar el perfil + rotated_profile = np.roll(profile, i) + # Correlación + score = np.corrcoef(chroma_avg, rotated_profile)[0, 1] + + if score > best_score: + best_score = score + best_mode = mode + best_key = NOTE_NAMES[i] + + # Formatear resultado + if best_key: + if best_mode == 'minor': + best_key = best_key + 'm' + confidence = max(0.0, min(1.0, (best_score + 1) / 2)) + return best_key, confidence + + except Exception as e: + logger.warning(f"Error detectando key: {e}") + + return None, 0.0 + + def _is_percussive(self, y: np.ndarray, sr: int) -> bool: + """ + Determina si un sonido es principalmente percusivo. + """ + try: + # Separar componentes armónicos y percusivos + y_harmonic, y_percussive = self.librosa.effects.hpss(y) + + # Calcular energía relativa + energy_harmonic = np.sum(y_harmonic ** 2) + energy_percussive = np.sum(y_percussive ** 2) + total_energy = energy_harmonic + energy_percussive + + if total_energy > 0: + percussive_ratio = energy_percussive / total_energy + return percussive_ratio > 0.6 + + except Exception as e: + logger.warning(f"Error en separación HPSS: {e}") + + # Fallback: usar duración como heurística + duration = len(y) / sr + return duration < 0.5 + + def _analyze_basic(self, file_path: str) -> AudioFeatures: + """ + Análisis básico sin dependencias externas. + Usa metadatos del archivo y nombre para inferir características. + """ + path = Path(file_path) + name = path.stem + + # Extraer del nombre + bpm = self._extract_bpm_from_name(name) + key = self._extract_key_from_name(name) + + # Estimar duración del archivo + duration = self._estimate_duration(file_path) + + # Clasificar por nombre + sample_type = self._classify_by_name(name) + + # Determinar características por tipo + is_percussive = sample_type in [ + SampleType.KICK, SampleType.SNARE, SampleType.CLAP, + SampleType.HAT, SampleType.HAT_CLOSED, SampleType.HAT_OPEN, + SampleType.PERC, SampleType.SHAKER, SampleType.TOM, + SampleType.CRASH, SampleType.RIDE + ] + is_harmonic = sample_type in [ + SampleType.BASS, SampleType.SYNTH, SampleType.PAD, + SampleType.LEAD, SampleType.PLUCK, SampleType.CHORD, + SampleType.VOCAL + ] + + # Valores por defecto basados en tipo + spectral_centroid = 5000.0 if is_percussive else 1000.0 + rms_energy = 0.5 + + suggested_genres = self._suggest_genres(sample_type, bpm, key) + + return AudioFeatures( + bpm=bpm, + key=key, + key_confidence=0.7 if key else 0.0, + duration=duration, + sample_rate=44100, + sample_type=sample_type, + spectral_centroid=spectral_centroid, + spectral_rolloff=spectral_centroid * 2, + zero_crossing_rate=0.1 if is_harmonic else 0.3, + rms_energy=rms_energy, + is_harmonic=is_harmonic, + is_percussive=is_percussive, + suggested_genres=suggested_genres + ) + + def _estimate_duration(self, file_path: str) -> float: + """Estima la duración del archivo de audio""" + try: + import wave + + ext = Path(file_path).suffix.lower() + + if ext == '.wav': + with wave.open(file_path, 'rb') as wav: + frames = wav.getnframes() + rate = wav.getframerate() + return frames / float(rate) + + elif ext in ('.mp3', '.ogg', '.flac', '.aif', '.aiff', '.m4a'): + windows_duration = self._estimate_duration_with_windows_shell(file_path) + if windows_duration > 0: + return windows_duration + # Estimación por tamaño de archivo + size = os.path.getsize(file_path) + # Aproximación: ~176KB por segundo para CD quality stereo + return size / (176.4 * 1024) + + except Exception as e: + logger.warning(f"Error estimando duración: {e}") + + return 0.0 + + def _estimate_duration_with_windows_shell(self, file_path: str) -> float: + """Obtiene la duración usando metadatos del shell de Windows cuando están disponibles.""" + if os.name != 'nt': + return 0.0 + + safe_path = file_path.replace("'", "''") + powershell_command = ( + f"$path = '{safe_path}'; " + "$shell = New-Object -ComObject Shell.Application; " + "$folder = $shell.Namespace((Split-Path $path)); " + "$file = $folder.ParseName((Split-Path $path -Leaf)); " + "$duration = $folder.GetDetailsOf($file, 27); " + "Write-Output $duration" + ) + try: + result = subprocess.run( + f'powershell -NoProfile -Command "{powershell_command}"', + capture_output=True, + text=True, + timeout=5, + check=False, + shell=True, + ) + value = (result.stdout or "").strip() + if not value: + return 0.0 + parts = value.split(':') + if len(parts) == 3: + return (int(parts[0]) * 3600) + (int(parts[1]) * 60) + float(parts[2]) + return 0.0 + except Exception: + return 0.0 + + def _extract_bpm_from_name(self, name: str) -> Optional[float]: + """Extrae BPM del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + r'[_\s\-](\d{2,3})\s*(?:BPM|bpm)?\s*(?:\.wav|\.mp3|\.aif)', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: + return float(bpm) + + return None + + def _extract_key_from_name(self, name: str) -> Optional[str]: + """Extrae key del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', + r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', + r'Key\s+([A-G][#b]?(?:m|min|minor)?)', + r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + # Detectar si es menor + is_minor = 'm' in key.lower() or 'min' in key.lower() + key = key.replace('min', '').replace('minor', '').replace('major', '') + key = key.rstrip('mM') + + if is_minor: + key = key + 'm' + + return key + + return None + + def _classify_sample_type(self, file_path: str, is_percussive: bool, + is_harmonic: bool, duration: float, + spectral_centroid: float, rms: float) -> SampleType: + """Clasifica el tipo de sample basado en características""" + # Primero intentar por nombre + sample_type = self._classify_by_name(Path(file_path).stem) + if sample_type != SampleType.UNKNOWN: + return sample_type + + # Clasificación por características de audio + if is_percussive: + if duration < 0.1: + if spectral_centroid < 2000: + return SampleType.KICK + elif spectral_centroid > 8000: + return SampleType.HAT_CLOSED + else: + return SampleType.SNARE + elif duration < 0.3: + return SampleType.CLAP + else: + return SampleType.PERC + + elif is_harmonic: + if spectral_centroid < 500: + return SampleType.BASS + elif duration > 4.0: + return SampleType.PAD + else: + return SampleType.SYNTH + + return SampleType.UNKNOWN + + def _classify_by_name(self, name: str) -> SampleType: + """Clasifica el tipo de sample basado en su nombre""" + name_lower = name.lower() + + # Mapeo de palabras clave a tipos + keywords = { + SampleType.KICK: ['kick', 'bd', 'bass drum', 'kickdrum', 'kik'], + SampleType.SNARE: ['snare', 'snr', 'sd', 'rim'], + SampleType.CLAP: ['clap', 'clp', 'handclap'], + SampleType.HAT_CLOSED: ['closed hat', 'closedhat', 'chh', 'closed'], + SampleType.HAT_OPEN: ['open hat', 'openhat', 'ohh', 'open'], + SampleType.HAT: ['hat', 'hihat', 'hi-hat', 'hh'], + SampleType.PERC: ['perc', 'percussion', 'conga', 'bongo', 'timb'], + SampleType.SHAKER: ['shaker', 'shake', 'tamb'], + SampleType.TOM: ['tom', 'tomtom'], + SampleType.CRASH: ['crash', 'cymbal'], + SampleType.RIDE: ['ride'], + SampleType.BASS: ['bass', 'bassline', 'sub', '808', 'reese'], + SampleType.SYNTH: ['synth', 'lead', 'arp', 'sequence'], + SampleType.PAD: ['pad', 'atmosphere', 'dron'], + SampleType.PLUCK: ['pluck'], + SampleType.CHORD: ['chord', 'stab'], + SampleType.VOCAL: ['vocal', 'vox', 'voice', 'speech', 'talk'], + SampleType.FX: ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit', 'noise'], + SampleType.LOOP: ['loop', 'full', 'groove'], + } + + for sample_type, words in keywords.items(): + for word in words: + if word in name_lower: + return sample_type + + return SampleType.UNKNOWN + + def _suggest_genres(self, sample_type: SampleType, bpm: Optional[float], + key: Optional[str]) -> List[str]: + """Sugiere géneros musicales apropiados para el sample""" + genres = [] + + if bpm: + if 118 <= bpm <= 128: + genres.extend(['house', 'tech-house', 'deep-house']) + elif 124 <= bpm <= 132: + genres.extend(['tech-house', 'techno']) + elif 132 <= bpm <= 142: + genres.extend(['techno', 'peak-time-techno']) + elif 142 <= bpm <= 150: + genres.extend(['trance', 'hard-techno']) + elif 160 <= bpm <= 180: + genres.extend(['drum-and-bass', 'neurofunk']) + elif bpm < 118: + genres.extend(['downtempo', 'ambient', 'lo-fi']) + + # Por tipo de sample + if sample_type in [SampleType.KICK, SampleType.SNARE, SampleType.CLAP]: + if not genres: + genres = ['techno', 'house'] + elif sample_type == SampleType.BASS: + if not genres: + genres = ['techno', 'house', 'bass-music'] + elif sample_type in [SampleType.SYNTH, SampleType.PAD]: + if not genres: + genres = ['trance', 'progressive', 'ambient'] + + return genres if genres else ['electronic'] + + def get_compatible_key(self, key: str, shift: int = 0) -> str: + """ + Obtiene una key compatible usando el círculo de quintas. + + Args: + key: Key original (ej: 'Am', 'F#m') + shift: Desplazamiento en el círculo (+1 = quinta arriba, -1 = quinta abajo) + + Returns: + Key resultante + """ + is_minor = key.endswith('m') + root = key.rstrip('m') + + if root not in NOTE_NAMES: + return key + + circle = CIRCLE_OF_FIFTHS_MINOR if is_minor else CIRCLE_OF_FIFTHS_MAJOR + + try: + idx = circle.index(key) + new_idx = (idx + shift) % 12 + return circle[new_idx] + except ValueError: + return key + + def calculate_key_compatibility(self, key1: str, key2: str) -> float: + """ + Calcula la compatibilidad entre dos keys (0-1). + + Usa el círculo de quintas: keys cercanas son más compatibles. + """ + if key1 == key2: + return 1.0 + + # Normalizar + def normalize(k): + is_minor = k.endswith('m') + root = k.rstrip('m') + # Convertir bemoles a sostenidos + root = root.replace('Db', 'C#').replace('Eb', 'D#') + root = root.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + return root + ('m' if is_minor else '') + + k1 = normalize(key1) + k2 = normalize(key2) + + if k1 == k2: + return 1.0 + + # Verificar si son modos diferentes de la misma nota + if k1.rstrip('m') == k2.rstrip('m'): + return 0.8 # Mismo root, diferente modo + + # Usar círculo de quintas + is_minor1 = k1.endswith('m') + is_minor2 = k2.endswith('m') + + if is_minor1 != is_minor2: + return 0.3 # Diferente modo, baja compatibilidad + + circle = CIRCLE_OF_FIFTHS_MINOR if is_minor1 else CIRCLE_OF_FIFTHS_MAJOR + + try: + idx1 = circle.index(k1) + idx2 = circle.index(k2) + distance = min(abs(idx1 - idx2), 12 - abs(idx1 - idx2)) + + # Compatibilidad decrece con la distancia + compatibility = max(0.0, 1.0 - (distance * 0.2)) + return compatibility + + except ValueError: + return 0.0 + + +# Instancia global +_analyzer: Optional[AudioAnalyzer] = None + + +def get_analyzer() -> AudioAnalyzer: + """Obtiene la instancia global del analizador""" + global _analyzer + if _analyzer is None: + _analyzer = AudioAnalyzer() + return _analyzer + + +def analyze_sample(file_path: str) -> Dict[str, Any]: + """ + Función de conveniencia para analizar un sample. + + Returns: + Diccionario con las características del sample + """ + analyzer = get_analyzer() + features = analyzer.analyze(file_path) + + return { + 'bpm': features.bpm, + 'key': features.key, + 'key_confidence': features.key_confidence, + 'duration': features.duration, + 'sample_rate': features.sample_rate, + 'sample_type': features.sample_type.value, + 'spectral_centroid': features.spectral_centroid, + 'rms_energy': features.rms_energy, + 'is_harmonic': features.is_harmonic, + 'is_percussive': features.is_percussive, + 'suggested_genres': features.suggested_genres, + } + + +def quick_analyze(file_path: str) -> Dict[str, Any]: + """ + Análisis rápido basado solo en el nombre del archivo. + No requiere dependencias externas. + """ + analyzer = AudioAnalyzer(backend="basic") + features = analyzer.analyze(file_path) + + return { + 'bpm': features.bpm, + 'key': features.key, + 'sample_type': features.sample_type.value, + 'suggested_genres': features.suggested_genres, + } + + +# Testing +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO) + + if len(sys.argv) < 2: + print("Uso: python audio_analyzer.py ") + sys.exit(1) + + file_path = sys.argv[1] + + print(f"\nAnalizando: {file_path}") + print("=" * 50) + + try: + result = analyze_sample(file_path) + + print("\nResultados:") + print(f" BPM: {result['bpm'] or 'No detectado'}") + print(f" Key: {result['key'] or 'No detectado'} (confianza: {result['key_confidence']:.2f})") + print(f" Duración: {result['duration']:.2f}s") + print(f" Tipo: {result['sample_type']}") + print(f" Géneros sugeridos: {', '.join(result['suggested_genres'])}") + print(f" Es percusivo: {result['is_percussive']}") + print(f" Es armónico: {result['is_harmonic']}") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py new file mode 100644 index 0000000..ec23c7f --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py @@ -0,0 +1,2466 @@ +""" +audio_resampler.py - Deriva transiciones y FX propios desde los samples elegidos. + +Phase 1 Improvements: +- Cache robusto con invalidacion por mtime, size y edad maxima +- Crossfades equal-power para eliminar clicks +- HPF/LPF sweeps suaves con overlap-add y filtros butterworth de 4to orden +- Normalizacion con soft limiting mejorado (curva cubica + lookahead) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +try: + import soundfile as sf +except ImportError: # pragma: no cover + sf = None + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + from scipy import signal as scipy_signal +except ImportError: # pragma: no cover + scipy_signal = None + + +logger = logging.getLogger("AudioResampler") + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +def _section_offsets(sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: + offsets: List[Tuple[Dict[str, Any], float, float]] = [] + cursor = 0.0 + for section in sections: + beats = _safe_float(section.get("beats", 0.0), _safe_float(section.get("bars", 8), 8.0) * 4.0) + start = float(cursor) + end = float(cursor + max(1.0, beats)) + offsets.append((section, start, end)) + cursor = end + return offsets + + +def _samples_from_seconds(seconds: float, sample_rate: int, min_samples: int = 256) -> int: + """Convierte segundos a samples con minimo garantizado. + + Args: + seconds: Duracion en segundos + sample_rate: Tasa de muestreo en Hz + min_samples: Minimo de samples a retornar (default: 256) + + Returns: + Numero de samples con minimo garantizado + """ + return max(min_samples, int(round(seconds * sample_rate))) + + +def _seconds_from_samples(samples: int, sample_rate: int, min_duration: float = 0.05) -> float: + """Convierte samples a segundos. + + Args: + samples: Numero de samples + sample_rate: Tasa de muestreo en Hz + min_duration: Duracion minima en segundos si samples es 0 (default: 0.05) + + Returns: + Duracion en segundos + """ + return samples / sample_rate if samples > 0 else min_duration + + + +def _ensure_2d_float(audio: np.ndarray) -> np.ndarray: + """Asegura que el array sea 2D float32 (samples, channels).""" + if audio is None or audio.size == 0: + return np.zeros((1, 1), dtype=np.float32) + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + return audio + + +def _safe_slice(audio: np.ndarray, start: int, end: int) -> np.ndarray: + """Extrae slice seguro que nunca retorna array vacio.""" + if audio is None or audio.size == 0: + channels = audio.shape[1] if (audio is not None and audio.ndim == 2) else 1 + return np.zeros((1, channels), dtype=np.float32) + start = max(0, min(start, audio.shape[0] - 1)) + end = max(start + 1, min(end, audio.shape[0])) + result = audio[start:end] + if result.size == 0: + return np.zeros((1, audio.shape[1]), dtype=np.float32) + return result + + +def _validate_mix_shapes(a: np.ndarray, b: np.ndarray) -> Tuple[bool, str]: + """Valida que dos arrays puedan mezclarse (broadcast compatible).""" + if a is None or b is None: + return False, "None array" + if a.size == 0 or b.size == 0: + return False, f"Empty array: a.shape={a.shape}, b.shape={b.shape}" + if a.ndim != b.ndim: + return False, f"Dimension mismatch: {a.ndim} vs {b.ndim}" + if a.shape[1] != b.shape[1]: + return False, f"Channel mismatch: {a.shape[1]} vs {b.shape[1]}" + return True, "OK" + + +class AudioResampler: + """Procesa audio para generar transiciones y FX. + + Phase 1 Improvements: + - Cache LRU con invalidacion por mtime, size y edad maxima + - Estadisticas de cache (hits/misses) + - Crossfades equal-power para mejor calidad + - HPF/LPF sweeps con filtros butterworth de 4to orden + - Soft limiting mejorado con curva cubica + """ + + # Limite maximo de archivos en cache + _CACHE_LIMIT: int = 50 + + # Edad maxima de cache en segundos (30 minutos) + _CACHE_MAX_AGE_S: float = 1800.0 + + # Tamanio maximo de cache en bytes (~500MB por defecto) + _CACHE_MAX_SIZE_BYTES: int = 500 * 1024 * 1024 + + # Valor de peak unificado para todos los renders (85% headroom) + _DEFAULT_PEAK: float = 0.85 + + # Crossfade samples por defecto (10ms a 44.1kHz) + _DEFAULT_CROSSFADE_SAMPLES: int = 441 + + # Minimos absolutos para evitar arrays vacios en procesamiento + _MIN_SAMPLES_FOR_FFT: int = 512 # Minimo para analisis espectral + _MIN_SAMPLES_FOR_WINDOW: int = 64 # Minimo para aplicar ventana + _MIN_SAMPLES_FOR_STRETCH: int = 100 # Minimo para time-stretch + _MIN_SAMPLES_FOR_SLICE: int = 32 # Minimo para slice de stutter + _MIN_SAMPLES_FOR_EFFECT: int = 256 # Minimo para aplicar cualquier efecto + _MIN_AUDIO_DURATION_S: float = 0.05 # 50ms minimo de audio + + def __init__(self, output_dir: Optional[str] = None, sample_rate: int = 44100): + local_root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + self.output_dir = Path(output_dir) if output_dir else local_root / "AbletonMCP_AI" / "generated_audio" + self.output_dir.mkdir(parents=True, exist_ok=True) + self.sample_rate = max(1, int(sample_rate)) # Validacion defensiva + + # Cache LRU para audio cargado: path::mtime_ns::size -> (audio_array, sample_rate, timestamp) + # El mtime_ns es parte de la key para invalidacion automatica por modificacion + # timestamp se usa para invalidacion por edad maxima + self._audio_cache: OrderedDict[str, Tuple[np.ndarray, int, float]] = OrderedDict() + + # Metadatos de cache para tracking de memoria + self._cache_sizes: Dict[str, int] = {} # path -> bytes + self._cache_total_bytes: int = 0 + + # Estadisticas de cache + self._cache_hits: int = 0 + self._cache_misses: int = 0 + + def _validate_audio_array(self, audio: np.ndarray, context: str = "audio") -> np.ndarray: + """Valida y normaliza un array de audio. + + Args: + audio: Array a validar + context: Descripcion del contexto para mensajes de error + + Returns: + Array validado como float32 y al menos 2D + + Raises: + ValueError: Si el array esta vacio o es invalido + """ + if audio is None: + raise ValueError(f"{context}: audio es None") + + audio = np.asarray(audio, dtype=np.float32) + + if audio.size == 0: + raise ValueError(f"{context}: audio array esta vacio") + + # Asegurar que sea 2D (samples, channels) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + + return audio + + def _validate_positive(self, value: float, name: str) -> float: + """Valida que un valor sea positivo. + + Args: + value: Valor a validar + name: Nombre del parametro para mensaje de error + + Returns: + Valor validado como float + + Raises: + ValueError: Si el valor no es positivo + """ + try: + val = float(value) + except (TypeError, ValueError): + raise ValueError(f"{name}: debe ser un numero valido, recibido {value!r}") + + if val <= 0: + raise ValueError(f"{name}: debe ser positivo, recibido {val}") + + return val + + def _get_cache_key(self, file_path: str, mtime_ns: Optional[int] = None, file_size: Optional[int] = None) -> str: + """Genera key de cache a partir del path absoluto, mtime y size. + + Args: + file_path: Ruta al archivo + mtime_ns: Tiempo de modificacion en nanosegundos (opcional) + file_size: Tamanio del archivo en bytes (opcional) + + Returns: + Key unica que incluye mtime y size si se proporcionan + """ + base_key = str(Path(file_path).resolve()) + parts = [base_key] + if mtime_ns is not None: + parts.append(str(mtime_ns)) + if file_size is not None: + parts.append(str(file_size)) + return "::".join(parts) + + def _cache_get(self, key: str) -> Optional[Tuple[np.ndarray, int]]: + """Obtiene audio del cache (LRU: mueve al final si existe). + + Returns: + Tupla (audio_array, sample_rate) o None si no existe o expiro + """ + if key not in self._audio_cache: + self._cache_misses += 1 + return None + + cached_data = self._audio_cache[key] + # Nuevo formato: (audio, sample_rate, timestamp) + if len(cached_data) == 3: + audio, sample_rate, timestamp = cached_data + # Verificar edad maxima + if time.time() - timestamp > self._CACHE_MAX_AGE_S: + logger.debug("Cache entry expired by age: %s", key) + self._evict_cache_entry(key) + self._cache_misses += 1 + return None + else: + # Formato legacy: (audio, sample_rate) + audio, sample_rate = cached_data[:2] + + # Mover al final (mas reciente) + self._audio_cache.move_to_end(key) + self._cache_hits += 1 + return (audio, sample_rate) + + def _evict_cache_entry(self, key: str) -> None: + """Evict una entrada especifica del cache y actualiza contadores.""" + if key in self._audio_cache: + if key in self._cache_sizes: + self._cache_total_bytes -= self._cache_sizes[key] + del self._cache_sizes[key] + del self._audio_cache[key] + + def _cache_put(self, key: str, audio: np.ndarray, sample_rate: int) -> None: + """Agrega audio al cache con limite LRU y de memoria.""" + # Calcular tamanio en bytes + entry_size = audio.nbytes + + # Si ya existe, actualizar y mover al final + if key in self._audio_cache: + old_size = self._cache_sizes.get(key, 0) + self._cache_total_bytes -= old_size + self._cache_sizes[key] = entry_size + self._cache_total_bytes += entry_size + self._audio_cache[key] = (audio, sample_rate, time.time()) + self._audio_cache.move_to_end(key) + return + + # Evict entries si excede limite de memoria + while (self._cache_total_bytes + entry_size > self._CACHE_MAX_SIZE_BYTES + and len(self._audio_cache) > 0): + oldest_key = next(iter(self._audio_cache)) + self._evict_cache_entry(oldest_key) + logger.debug("Evicted cache entry (memory limit): %s", oldest_key) + + # Si el cache esta lleno por cantidad, eliminar el mas antiguo (primero) + while len(self._audio_cache) >= self._CACHE_LIMIT: + oldest_key = next(iter(self._audio_cache)) + self._evict_cache_entry(oldest_key) + logger.debug("Evicted cache entry (count limit): %s", oldest_key) + + # Agregar nueva entrada + self._cache_sizes[key] = entry_size + self._cache_total_bytes += entry_size + self._audio_cache[key] = (audio, sample_rate, time.time()) + + def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]: + """Carga un archivo de audio con cache LRU e invalidacion por mtime, size y edad. + + Args: + file_path: Ruta al archivo de audio + + Returns: + Tupla (audio_array, sample_rate) + + Raises: + RuntimeError: Si no se puede leer el archivo + """ + if not file_path: + raise RuntimeError("file_path esta vacio") + + path = Path(file_path) + + if not path.exists(): + raise RuntimeError(f"Archivo no encontrado: {path}") + + # Obtener mtime y size antes de cualquier operacion + stat_info = path.stat() + mtime_ns = stat_info.st_mtime_ns + file_size = stat_info.st_size + cache_key = self._get_cache_key(file_path, mtime_ns, file_size) + + # Intentar obtener del cache (la key incluye mtime y size, si cambio no se encontrara) + cached = self._cache_get(cache_key) + if cached is not None: + duration_s = len(cached[0]) / cached[1] + logger.debug("Cache hit for %s (sample_rate=%d, duration=%.2fs, hits=%d, misses=%d)", + path.name, cached[1], duration_s, self._cache_hits, self._cache_misses) + # Devolver copia para evitar mutaciones + return np.array(cached[0], dtype=np.float32, copy=True), cached[1] + + logger.debug("Cache miss for %s, reading from disk (hits=%d, misses=%d)", + path.name, self._cache_hits, self._cache_misses) + + if sf is not None: + try: + audio, sample_rate = sf.read(str(path), always_2d=True, dtype="float32") + + # Validacion defensiva - verificar que no este vacio + if audio.size == 0: + logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) + silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) + return silence, self.sample_rate + + duration_s = len(audio) / sample_rate + logger.debug("Loaded from disk via soundfile: %s (sample_rate=%d, duration=%.2fs, channels=%d)", + path.name, sample_rate, duration_s, audio.shape[1]) + + if sample_rate != self.sample_rate: + logger.debug("Resampling %s from %d to %d Hz", path.name, sample_rate, self.sample_rate) + audio = self._resample_audio(audio, sample_rate, self.sample_rate) + sample_rate = self.sample_rate + + # Guardar en cache + self._cache_put(cache_key, audio, sample_rate) + logger.debug("Cached audio: %s (total_cache_size=%.2fMB)", path.name, self._cache_total_bytes / (1024*1024)) + return np.array(audio, dtype=np.float32, copy=True), sample_rate + + except Exception as exc: + logger.debug("soundfile fallo para %s: %s", path.name, exc) + + if librosa is None: + raise RuntimeError(f"No se pudo leer audio (sin soundfile ni librosa): {path.name}") + + logger.debug("Falling back to librosa for: %s", path.name) + try: + audio, sample_rate = librosa.load(str(path), sr=self.sample_rate, mono=True) + audio = np.asarray(audio, dtype=np.float32).reshape(-1, 1) + audio = np.repeat(audio, 2, axis=1) + + # Validacion defensiva - verificar que no este vacio + if audio.size == 0: + logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) + silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) + return silence, self.sample_rate + + duration_s = len(audio) / self.sample_rate + logger.debug("Loaded via librosa: %s (sample_rate=%d, duration=%.2fs, channels=2)", + path.name, self.sample_rate, duration_s) + + # Guardar en cache + self._cache_put(cache_key, audio, self.sample_rate) + logger.debug("Cached audio: %s", cache_key) + return np.array(audio, dtype=np.float32, copy=True), self.sample_rate + + except Exception as exc: + logger.error("No se pudo leer audio con librosa: %s: %s", path.name, exc) + raise RuntimeError(f"No se pudo leer audio con librosa: {path.name}: {exc}") + + def _write_audio(self, file_path: Path, audio: np.ndarray, sample_rate: int) -> str: + """Escribe audio a archivo WAV. + + Args: + file_path: Ruta de destino + audio: Array de audio + sample_rate: Sample rate + + Returns: + Ruta del archivo escrito como string + + Raises: + RuntimeError: Si soundfile no esta disponible o el audio es invalido + """ + if sf is None: + raise RuntimeError("soundfile no disponible para escribir audio") + + # Validacion defensiva + audio = self._validate_audio_array(audio, context="_write_audio") + sample_rate = self._validate_positive(sample_rate, "sample_rate") + + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + if audio.shape[1] == 1: + audio = np.repeat(audio, 2, axis=1) + sf.write(str(file_path), audio, int(sample_rate)) + return str(file_path) + + def _resample_audio(self, audio: np.ndarray, source_sr: int, target_sr: int) -> np.ndarray: + """Cambia el sample rate de audio. + + Args: + audio: Array de audio + source_sr: Sample rate origen + target_sr: Sample rate destino + + Returns: + Audio resampleado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_resample_audio") + source_sr = max(1, int(source_sr)) + target_sr = max(1, int(target_sr)) + + if source_sr == target_sr: + return np.array(audio, dtype=np.float32) + + factor = float(target_sr) / float(source_sr) + target_len = max(1, int(round(audio.shape[0] * factor))) + return self._stretch_to_length(audio, target_len) + + def _stretch_to_length(self, audio: np.ndarray, target_len: int) -> np.ndarray: + """Estira o comprime audio a una longitud especifica. + + Usa scipy.signal.resample_poly si esta disponible (mejor calidad con anti-aliasing), + sino scipy.signal.resample (FFT-based), sino librosa.resample, sino np.interp como fallback. + + Args: + audio: Array de audio (samples, channels) + target_len: Longitud objetivo en samples + + Returns: + Audio estirado/comprimido + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_stretch_to_length") + target_len = max(1, int(target_len)) + + # Validacion adicional: si el audio esta vacio o target_len es 0, retornar silencio + if audio.size == 0 or target_len == 0: + logger.warning("_stretch_to_length: audio vacio o target_len=0, retornando silencio de longitud %d", target_len) + return np.zeros((target_len, 2), dtype=np.float32) + + if audio.shape[0] == target_len: + return np.array(audio, dtype=np.float32) + + # Caso edge: array de 1 sample + if audio.shape[0] <= 1: + return np.repeat(np.asarray(audio, dtype=np.float32), target_len, axis=0) + + original_len = audio.shape[0] + + def _fit_channel_length(channel_audio: np.ndarray) -> np.ndarray: + fitted = np.asarray(channel_audio, dtype=np.float32).reshape(-1) + current_len = fitted.shape[0] + if current_len == target_len: + return fitted + if current_len > target_len: + return fitted[:target_len] + if current_len <= 0: + return np.zeros(target_len, dtype=np.float32) + pad_value = float(fitted[-1]) + padding = np.full(target_len - current_len, pad_value, dtype=np.float32) + return np.concatenate([fitted, padding], axis=0) + + # Intentar usar scipy.signal.resample_poly (mejor calidad con anti-aliasing) + if scipy_signal is not None: + try: + from fractions import Fraction + # Calcular ratio como fraccion simplificada + ratio = Fraction(target_len, original_len).limit_denominator(1000) + up = ratio.numerator + down = ratio.denominator + + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + # resample_poly usa filtros anti-aliasing para mejor calidad + resampled = scipy_signal.resample_poly(audio[:, channel], up, down) + stretched[:, channel] = _fit_channel_length(resampled) + return stretched + except Exception as exc: + logger.debug("scipy.signal.resample_poly fallo: %s, intentando resample normal", exc) + # Fallback a resample normal dentro del mismo bloque + try: + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + # resample usa FFT para mejor calidad que interpolacion lineal + stretched[:, channel] = scipy_signal.resample( + audio[:, channel], target_len + ).astype(np.float32) + return stretched + except Exception as exc2: + logger.debug("scipy.signal.resample fallo: %s, usando fallback", exc2) + + # Intentar usar librosa.resample (buena calidad) + if librosa is not None: + try: + # librosa.resample requiere sample rates originales y destino + # Usamos valores ficticios que producen el ratio correcto + orig_sr = original_len + target_sr = target_len + + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + resampled = librosa.resample( + audio[:, channel], + orig_sr=orig_sr, + target_sr=target_sr, + res_type="linear" # Mas rapido, pero mejor que np.interp puro + ) + stretched[:, channel] = _fit_channel_length(resampled) + return stretched + except Exception as exc: + logger.debug("librosa.resample fallo: %s, usando np.interp", exc) + + # Fallback: np.interp (interpolacion lineal - menor calidad) + source_x = np.linspace(0.0, 1.0, original_len, endpoint=True) + target_x = np.linspace(0.0, 1.0, target_len, endpoint=True) + stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) + for channel in range(audio.shape[1]): + stretched[:, channel] = np.interp(target_x, source_x, audio[:, channel]).astype(np.float32) + return stretched + + def _normalize(self, audio: np.ndarray, peak: float = None, soft_limit: bool = True) -> np.ndarray: + """Normaliza el pico del audio con soft limiting mejorado. + + Phase 1 Improvements: + - Soft knee con curva cubica suave (mas natural que lineal) + - Mejor preservacion de dinamica en el rango normal + + Args: + audio: Array de audio + peak: Nivel de pico objetivo (0.01 - 1.0). Por defecto usa _DEFAULT_PEAK (0.85). + soft_limit: Si True, aplica soft knee con curva cubica. + + Returns: + Audio normalizado + """ + # Usar valor por defecto unificado si no se especifica + if peak is None: + peak = self._DEFAULT_PEAK + + # Validacion defensiva + if audio is None or audio.size == 0: + return audio + + audio = np.asarray(audio, dtype=np.float32, copy=True) + peak = max(0.01, min(1.0, float(peak))) + + current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if current_peak <= 1e-6: + return audio + + # Aplicar soft limiting mejorado si esta habilitado + if soft_limit: + # Soft knee con curva cubica: mas suave que lineal, menos agresivo que tanh + # La curva cubica preserva mas dinamica en el rango normal + knee_start = peak * 0.75 # Knee empieza al 75% del peak + + abs_audio = np.abs(audio) + mask = abs_audio > knee_start + + if np.any(mask): + sign = np.sign(audio) + # Calcular posicion relativa dentro del knee (0 a 1) + knee_range = peak - knee_start + over_knee = abs_audio[mask] - knee_start + relative_pos = np.clip(over_knee / knee_range, 0.0, 1.0) + + # Curva cubica: (1 - (1-x)^3) para compresion suave + # Esto da una curva que empieza gradual y se aplane hacia el peak + compression_factor = 1.0 - np.power(1.0 - relative_pos, 3.0) + + # Aplicar compresion manteniendo la senal por debajo del peak + compressed = knee_start + knee_range * compression_factor + audio[mask] = sign[mask] * compressed + + # Recalcular peak despues del soft limiting + current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if current_peak <= 1e-6: + return audio + + # Normalizar al peak objetivo + return (audio / current_peak) * peak + + def _apply_fade( + self, + audio: np.ndarray, + fade_in_s: float = 0.02, + fade_out_s: float = 0.04, + fade_curve: str = "linear" + ) -> np.ndarray: + """Aplica fade in y fade out al audio. + + Args: + audio: Array de audio + fade_in_s: Duracion del fade in en segundos + fade_out_s: Duracion del fade out en segundos + fade_curve: Tipo de curva ("linear", "logarithmic", "exponential") + + Returns: + Audio con fades aplicados + """ + # Validacion defensiva + if audio is None or audio.size == 0: + return np.zeros((1, 2), dtype=np.float32) + + output = np.array(audio, dtype=np.float32, copy=True) + + # Asegurar 2D + if output.ndim == 1: + output = output.reshape(-1, 1) + + total = output.shape[0] + if total <= 2: + return output + + # Validar y clamp tiempos de fade + fade_in_s = max(0.0, float(fade_in_s)) + fade_out_s = max(0.0, float(fade_out_s)) + + fade_in = min(total, max(0, int(round(fade_in_s * self.sample_rate)))) + fade_out = min(total, max(0, int(round(fade_out_s * self.sample_rate)))) + + # Funcion auxiliar para generar curvas de fade + def _generate_fade_curve(length: int, direction: str) -> np.ndarray: + """Genera curva de fade segun el tipo especificado.""" + if fade_curve == "logarithmic": + # Curva logaritmica: inicio suave, transicion gradual + # Usa curva tipo -cos(0 a pi/2) o equivalente: 1 - e^(-3x) normalizado + x = np.linspace(0.0, 1.0, length, dtype=np.float32) + # Logarithmic-like curve: 1 - exp(-k*x) normalizado + k = 4.0 # Factor de curvatura + curve = (1.0 - np.exp(-k * x)) / (1.0 - np.exp(-k)) + elif fade_curve == "exponential": + # Curva exponencial: inicio rapido, final gradual + x = np.linspace(0.0, 1.0, length, dtype=np.float32) + curve = np.power(x, 2.0) # x^2 para curva exponencial simple + else: + # Linear por defecto + curve = np.linspace(0.0, 1.0, length, dtype=np.float32) + + if direction == "out": + curve = curve[::-1] + return curve.reshape(-1, 1) + + if fade_in > 0: + fade_in_curve = _generate_fade_curve(fade_in, "in") + output[:fade_in] *= fade_in_curve + if fade_out > 0: + fade_out_curve = _generate_fade_curve(fade_out, "out") + output[-fade_out:] *= fade_out_curve + return output + + def _apply_short_crossfade(self, audio: np.ndarray, fade_samples: int = 220, equal_power: bool = True) -> np.ndarray: + """Aplica un crossfade corto (5ms por defecto) en ambos extremos del audio. + + Phase 1 Improvements: + - Crossfades equal-power (sin/cos) para mejor calidad y menos artefactos + - Los crossfades equal-power mantienen la energia constante durante la transicion + + Esto elimina clicks al concatenar segmentos de audio extraidos. + + Args: + audio: Array de audio (samples, channels) + fade_samples: Numero de samples para el fade (220 = ~5ms a 44100Hz) + equal_power: Si True, usa curvas equal-power (sin/cos), sino lineales + + Returns: + Audio con crossfades aplicados + """ + # Validacion defensiva + if audio is None or audio.size == 0: + return np.zeros((1, 2), dtype=np.float32) + + output = np.array(audio, dtype=np.float32, copy=True) + + # Asegurar 2D + if output.ndim == 1: + output = output.reshape(-1, 1) + + total = output.shape[0] + if total <= 4: + return output + + # Clamp fade_samples a rango valido + fade_samples = max(1, min(fade_samples, total // 2)) + + if equal_power: + # Equal-power crossfade: mantiene energia constante + # fade_in = sin(x * pi/2), fade_out = cos(x * pi/2) + x = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + fade_in_curve = np.sin(x * np.pi / 2.0).reshape(-1, 1) + fade_out_curve = np.cos(x * np.pi / 2.0).reshape(-1, 1) + else: + # Fallback a curvas lineales + fade_in_curve = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32).reshape(-1, 1) + fade_out_curve = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32).reshape(-1, 1) + + output[:fade_samples] *= fade_in_curve + output[-fade_samples:] *= fade_out_curve + + return output + + def _extract_tail(self, audio: np.ndarray, seconds: float, min_length: float = 0.1) -> np.ndarray: + """Extrae los ultimos N segundos de audio con crossfade corto para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion a extraer en segundos + min_length: Longitud minima en segundos (default: 0.1s = 4410 samples) + + Returns: + Segmento de audio extraido con crossfade aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_extract_tail") + seconds = max(0.001, float(seconds)) # Al menos 1ms + min_length = max(0.001, float(min_length)) # Al menos 1ms + + samples = max(1, int(round(seconds * self.sample_rate))) + min_samples = max(1, int(round(min_length * self.sample_rate))) + + # Si el audio es muy corto, retornar todo el audio + if audio.shape[0] <= samples: + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + segment = np.array(audio[-samples:], dtype=np.float32, copy=True) + + # Validar que el segmento no sea muy corto + if segment.shape[0] < min_samples: + logger.warning("_extract_tail: segmento muy corto (%d samples), usando todo el audio disponible", segment.shape[0]) + segment = np.array(audio, dtype=np.float32, copy=True) + + # Aplicar crossfade corto (5ms) para eliminar clicks en el corte + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _extract_center(self, audio: np.ndarray, seconds: float) -> np.ndarray: + """Extrae el centro del audio con crossfades cortos para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion a extraer en segundos + + Returns: + Segmento de audio extraido con crossfades aplicados + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_extract_center") + seconds = max(0.001, float(seconds)) # Al menos 1ms + + samples = max(1, int(round(seconds * self.sample_rate))) + if audio.shape[0] <= samples: + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + start = max(0, (audio.shape[0] - samples) // 2) + segment = np.array(audio[start:start + samples], dtype=np.float32, copy=True) + + # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _find_hot_slice(self, audio: np.ndarray, seconds: float, min_samples: int = -1) -> np.ndarray: + """Encuentra el segmento con mayor energia con crossfades cortos para eliminar clicks. + + Args: + audio: Array de audio + seconds: Duracion del segmento en segundos + min_samples: Longitud minima del resultado en samples (default: 1000) + + Returns: + Segmento de mayor energia con crossfades aplicados + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_find_hot_slice") + seconds = max(0.001, float(seconds)) # Al menos 1ms + # Usar constante minima de efecto si no se especifica + if min_samples < 0: + min_samples = self._MIN_SAMPLES_FOR_EFFECT + else: + min_samples = max(self._MIN_SAMPLES_FOR_EFFECT, int(min_samples)) + + samples = max(min_samples, int(round(seconds * self.sample_rate))) + if audio.shape[0] <= samples: + # Si el audio es muy corto, paddear a min_samples + if audio.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples", audio.shape[0], min_samples) + padding = np.zeros((min_samples - audio.shape[0], audio.shape[1]), dtype=np.float32) + audio = np.concatenate([audio, padding], axis=0) + segment = np.array(audio, dtype=np.float32, copy=True) + # Aplicar crossfade incluso si es todo el audio + return self._apply_short_crossfade(segment, fade_samples=220) + + mono = np.mean(np.abs(audio), axis=1) + window = max(8, samples) + energy = np.convolve(mono, np.ones(window, dtype=np.float32), mode="valid") + + # Handle edge case: energia vacia + if energy.size == 0: + segment = np.array(audio[:samples], dtype=np.float32, copy=True) + # Validar longitud minima + if segment.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples (empty energy)", segment.shape[0], min_samples) + padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) + segment = np.concatenate([segment, padding], axis=0) + return self._apply_short_crossfade(segment, fade_samples=220) + + start = int(np.argmax(energy)) + segment = np.array(audio[start:start + samples], dtype=np.float32, copy=True) + + # Validar longitud minima del resultado + if segment.shape[0] < min_samples: + logger.debug("HOT_SLICE: padded short audio from %d to %d samples (result)", segment.shape[0], min_samples) + padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) + segment = np.concatenate([segment, padding], axis=0) + + # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks + segment = self._apply_short_crossfade(segment, fade_samples=220) + + return segment + + def _apply_short_reverb(self, audio: np.ndarray, decay: float = 0.3, delay_ms: float = 50.0) -> np.ndarray: + """Aplica un reverb corto mediante delays con feedback. + + Simula una respuesta impulsional corta (~100ms) para dar profundidad + al audio invertido sin crear una cola larga. + + Args: + audio: Array de audio (samples, channels) + decay: Factor de decaimiento del reverb (0.0 - 0.8) + delay_ms: Delay base en milisegundos + + Returns: + Audio con reverb aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_short_reverb") + decay = max(0.0, min(0.8, float(decay))) + delay_ms = max(5.0, min(200.0, float(delay_ms))) + + output = np.array(audio, dtype=np.float32, copy=True) + total_samples = output.shape[0] + + # Calcular samples de delay base + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + if delay_samples < 1 or total_samples < delay_samples + 1: + return output + + # Crear multiples taps de delay para simular reverb + # Taps con diferentes tiempos y ganancias + taps = [ + (1, 1.0, decay * 0.6), # 1er eco temprano + (int(delay_samples * 1.3), 0.9, decay * 0.4), # 2do eco + (int(delay_samples * 1.7), 0.85, decay * 0.3), # 3er eco + (int(delay_samples * 2.2), 0.8, decay * 0.2), # 4to eco (difuso) + ] + + for delay, gain, feedback in taps: + if delay >= total_samples: + continue + # Aplicar delay con feedback + delayed = np.zeros_like(output) + delayed[delay:] = output[:-delay] * gain * feedback + output = output + delayed + + # Mezclar wet/dry (30% wet) + wet = output * 0.3 + dry = audio * 0.7 + result = dry + wet + + # Normalizar para evitar clipping + max_val = np.max(np.abs(result)) + if max_val > 0.95: + result = result * (0.95 / max_val) + + return result.astype(np.float32) + + def _apply_delay_feedback( + self, + audio: np.ndarray, + delay_ms: float = 150.0, + feedback: float = 0.35, + mix: float = 0.25, + num_taps: int = 3 + ) -> np.ndarray: + """Aplica delay con feedback sutil para anadir profundidad y textura. + + Crea repeticiones que decaen gradualmente, ideal para reverse FX. + + Args: + audio: Array de audio (samples, channels) + delay_ms: Tiempo entre repeticiones en milisegundos (default: 150ms) + feedback: Factor de decaimiento por repeticion (0.0 - 0.7, default: 0.35) + mix: Nivel de la senal wet (0.0 - 0.5, default: 0.25) + num_taps: Numero de repeticiones (1-5, default: 3) + + Returns: + Audio con delay aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_delay_feedback") + delay_ms = max(10.0, min(500.0, float(delay_ms))) + feedback = max(0.0, min(0.7, float(feedback))) + mix = max(0.0, min(0.5, float(mix))) + num_taps = max(1, min(5, int(num_taps))) + + output = np.zeros_like(audio, dtype=np.float32) + total_samples = audio.shape[0] + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + + # Validar que hay suficiente espacio para el delay + if delay_samples < 1 or total_samples < delay_samples + 1: + return np.array(audio, dtype=np.float32) + + # Copiar la senal dry + output = np.array(audio, dtype=np.float32, copy=True) + + # Anadir taps de delay con feedback decreciente + current_gain = feedback + for tap in range(1, num_taps + 1): + tap_delay = delay_samples * tap + if tap_delay >= total_samples: + break + + # Crear senal delayada con gain decreciente + delayed = np.zeros_like(audio) + delayed[tap_delay:] = audio[:-tap_delay] * current_gain + + # Mezclar con output + output = output + delayed + + # Reducir gain para siguiente tap + current_gain *= feedback + + # Mezclar wet/dry + dry = audio * (1.0 - mix) + wet = output * mix + result = dry + wet + + # Normalizar para evitar clipping + max_val = np.max(np.abs(result)) + if max_val > 0.95: + result = result * (0.95 / max_val) + + return result.astype(np.float32) + + def _apply_hpf(self, audio: np.ndarray, cutoff_hz: float = 100.0) -> np.ndarray: + """Aplica un filtro high-pass para limpiar frecuencias bajas (mud). + + Usa scipy.signal.butter si esta disponible, sino una aproximacion + por diferenciacion de primer orden. + + Args: + audio: Array de audio (samples, channels) + cutoff_hz: Frecuencia de corte en Hz (tipica: 80-120 Hz) + + Returns: + Audio filtrado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_hpf") + cutoff_hz = max(20.0, min(500.0, float(cutoff_hz))) + + output = np.zeros_like(audio, dtype=np.float32) + num_channels = audio.shape[1] + total_samples = audio.shape[0] + + # Intentar usar scipy para mejor calidad + if scipy_signal is not None: + try: + # Filtro Butterworth high-pass de 2do orden + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, cutoff_hz / nyquist) # Evitar Nyquist + b, a = scipy_signal.butter(2, normalized_cutoff, btype='high', analog=False) + for ch in range(num_channels): + output[:, ch] = scipy_signal.filtfilt(b, a, audio[:, ch]).astype(np.float32) + return output + except Exception as exc: + logger.debug("scipy HPF fallo: %s, usando fallback por diferenciacion", exc) + + # Fallback: filtro high-pass por diferenciacion (RC) + rc = 1.0 / (2.0 * 3.14159265359 * cutoff_hz) + dt = 1.0 / self.sample_rate + alpha = rc / (rc + dt) + + for ch in range(num_channels): + prev_input = 0.0 + prev_output = 0.0 + for i in range(total_samples): + current_input = float(audio[i, ch]) + output[i, ch] = alpha * (prev_output + current_input - prev_input) + prev_input = current_input + prev_output = float(output[i, ch]) + + return output.astype(np.float32) + + def _apply_hpf_sweep(self, audio: np.ndarray, start_hz: float = 200.0, end_hz: float = 2000.0) -> np.ndarray: + """Aplica un HPF sweep que va desde start_hz hasta end_hz. + + Phase 1 Improvements: + - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) + - Overlap-add mejorado con 75% overlap para transiciones mas suaves + - Normalizacion de ventana para evitar artefactos de amplitud + + El filtro high-pass barre su frecuencia de corte a lo largo del audio, + creando el clasico efecto de "sweep" usado en risers. + + Args: + audio: Array de audio (samples, channels) + start_hz: Frecuencia inicial del HPF (default 200Hz) + end_hz: Frecuencia final del HPF (default 2000Hz) + + Returns: + Audio con HPF sweep aplicado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_hpf_sweep") + start_hz = max(20.0, min(float(start_hz), self.sample_rate / 2.0 - 100)) + end_hz = max(start_hz, min(float(end_hz), self.sample_rate / 2.0 - 100)) + + # Sin scipy, devolver audio sin cambios + if scipy_signal is None: + logger.debug("scipy_signal no disponible, saltando HPF sweep") + return np.array(audio, dtype=np.float32) + + total_samples = audio.shape[0] + output = np.zeros_like(audio, dtype=np.float32) + + # Procesar en frames con overlap para evitar glitches + # Frames mas pequenos (25ms) con 75% overlap para transiciones mas suaves + frame_size = int(0.025 * self.sample_rate) # 25ms frames + hop_size = frame_size // 4 # 75% overlap + num_frames = max(1, (total_samples - frame_size) // hop_size + 1) + + # Ventana de Hann para overlap-add + window = np.hanning(frame_size).astype(np.float32) + + # Buffer para normalizacion de overlap + window_sum = np.zeros(total_samples, dtype=np.float32) + + for i in range(num_frames): + start_sample = i * hop_size + end_sample = min(start_sample + frame_size, total_samples) + + # Frecuencia de corte para este frame (interpolacion exponencial) + progress = i / max(1, num_frames - 1) + cutoff_hz = start_hz * (end_hz / start_hz) ** progress + + # Extraer frame + frame = audio[start_sample:end_sample] + actual_frame_size = frame.shape[0] + + if actual_frame_size < frame_size: + # Padding si es el ultimo frame + padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) + padded[:actual_frame_size] = frame + frame = padded + actual_window = window.copy() + actual_window[actual_frame_size:] = 0.0 + else: + actual_window = window + + # Aplicar HPF Butterworth de 4to orden (24dB/octava) + try: + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, cutoff_hz / nyquist) + + # Filtro de 4to orden para pendiente mas pronunciada + b, a = scipy_signal.butter(4, normalized_cutoff, btype="high", output="ba") + + # Aplicar filtro a cada canal con filtfilt para fase cero + filtered = np.zeros_like(frame) + for ch in range(frame.shape[1]): + filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) + + # Aplicar ventana + windowed = filtered * actual_window.reshape(-1, 1) + + # Acumular en output (overlap-add) + out_len = min(actual_frame_size, total_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + except Exception as exc: + logger.debug("Error en HPF sweep frame %d: %s", i, exc) + # Fallback: copiar frame con ventana + windowed = frame * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, total_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + # Normalizar por la suma de ventanas para compensar overlap + window_sum = np.maximum(window_sum, 1e-8) + output = output / window_sum.reshape(-1, 1) + + return output.astype(np.float32) + + def _apply_saturator(self, audio: np.ndarray, drive: float = 0.3) -> np.ndarray: + """Aplica saturacion suave usando tanh. + + La saturacion tanh simula el comportamiento de equipos analogicos, + anadiendo harmonicos de forma musical y suavizando los picos. + + Args: + audio: Array de audio (samples, channels) + drive: Cantidad de saturacion (0.0 - 1.0, default 0.3) + + Returns: + Audio saturado + """ + # Validaciones defensivas + audio = self._validate_audio_array(audio, context="_apply_saturator") + drive = max(0.0, min(1.0, float(drive))) + + if drive <= 0.001: + return np.array(audio, dtype=np.float32) + + # Saturacion suave usando tanh + gain = 1.0 + drive + saturated = np.tanh(audio * gain) / gain + + return saturated.astype(np.float32) + + def _render_reverse_fx(self, source_path: str, duration_s: float = 4.0, project_bpm: float = 120.0) -> np.ndarray: + """Renderiza efecto de reverse profesional mejorado. + + Incluye: + - Reverb profundo antes del reverse + - HPF agresivo para limpiar mud + - Swell exponencial dramatico + - Delay feedback sutil + - Fade-in con curva logaritmica natural + - Integracion con BPM del proyecto + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + project_bpm: BPM del proyecto para sincronizacion (default: 120.0) + + Returns: + Audio procesado con reverse FX profesional + """ + # Validaciones defensivas + duration_s = max(0.1, float(duration_s)) + project_bpm = max(60.0, min(200.0, float(project_bpm or 120.0))) + logger.debug( + "Rendering REVERSE FX: source=%s, duration=%.1fs, bpm=%.0f", + Path(source_path).name, duration_s, project_bpm + ) + + # Largar y preparar segmento + audio, _ = self._load_audio(source_path) + # Usar constante minima para efecto + min_tail_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + tail_duration = max(min_tail_duration, duration_s * 0.85) + if tail_duration == min_tail_duration: + logger.debug("Using minimum tail duration %.3fs for short audio in reverse", min_tail_duration) + segment = self._extract_tail(audio, tail_duration) + reversed_audio = np.flip(segment, axis=0) + reversed_audio = self._stretch_to_length(reversed_audio, int(round(duration_s * self.sample_rate))) + + # 1. Aplicar reverb PROFUNDO para dar cuerpo antes del reverse + # Decay mas alto (0.55) y delay mas largo (90ms) para profundidad + reversed_audio = self._apply_short_reverb(reversed_audio, decay=0.55, delay_ms=90.0) + + # 2. HPF AGRESIVO para limpiar mud en frecuencias bajas + # Subir de 100Hz a 180Hz para reverse mas limpio y brillante + reversed_audio = self._apply_hpf(reversed_audio, cutoff_hz=180.0) + + # 3. Aplicar SWELL EXPONENCIAL DRAMATICO + # Usar ramp exponencial de volumen para build-up dramatico + length = reversed_audio.shape[0] + # Curva exponencial: comienza muy bajo y crece dramaticamente + # El factor 5.0 da un rango de ~-14dB a 0dB + swell_ramp = np.exp(np.linspace(np.log(0.05), np.log(1.0), length, dtype=np.float32)).reshape(-1, 1) + reversed_audio = reversed_audio * swell_ramp + + # 4. Aplicar DELAY FEEDBACK SUTIL para textura y espacio + # Delay sincronizado con BPM (1/8 de nota = 60*1000/(bpm*2) ms) + delay_ms_sync = (60000.0 / project_bpm) / 2.0 # 1/8 de nota + reversed_audio = self._apply_delay_feedback( + reversed_audio, + delay_ms=delay_ms_sync, + feedback=0.3, + mix=0.2, + num_taps=2 + ) + + # 5. Fade-in con CURVA LOGARITMICA para transicion natural + # Fade-in mas largo (0.4s) con curva logaritmica + reversed_audio = self._apply_fade( + reversed_audio, + fade_in_s=0.4, + fade_out_s=0.05, + fade_curve="logarithmic" + ) + + result = self._normalize(reversed_audio) + + final_duration = len(result) / self.sample_rate + logger.debug("REVERSE_FX: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _render_riser(self, source_path: str, duration_s: float = 8.0, bpm: float = 128.0) -> np.ndarray: + """Renderiza efecto de riser profesional con HPF sweep, ramp exponencial con plateau, y saturacion mejorada. + + Phase 1 Improvements: + - BPM-synced for better musical timing + - Longer plateau before the peak for sustain + - Enhanced HPF sweep curve (80Hz -> 3500Hz for more dramatic sweep) + - Added mid-frequency boost for presence + - Better saturation curve with progressive drive + - Longer sustain before final peak + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + bpm: BPM del proyecto para sincronizacion (default: 128.0) + + Returns: + Audio procesado + """ + duration_s = max(0.1, float(duration_s)) + bpm = max(60.0, min(200.0, float(bpm or 128.0))) + logger.debug("Rendering RISER FX: source=%s, duration=%.1fs, bpm=%.0f", Path(source_path).name, duration_s, bpm) + + audio, _ = self._load_audio(source_path) + min_source_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + beat_duration = 60.0 / bpm + source_duration = max(min_source_duration, min(beat_duration * 4.0, duration_s / 3.5)) + if source_duration == min_source_duration: + logger.debug("Using minimum source duration %.3fs for short audio in riser", min_source_duration) + segment = self._extract_center(audio, source_duration) + + stages: List[np.ndarray] = [] + for speed in (1.0, 0.88, 0.75, 0.62): + target_len = max(self._MIN_SAMPLES_FOR_STRETCH, int(round(segment.shape[0] * speed))) + sped = self._stretch_to_length(segment, target_len) + stages.append(sped) + combined = np.concatenate(stages, axis=0) + combined = self._stretch_to_length(combined, int(round(duration_s * self.sample_rate))) + + num_samples = combined.shape[0] + logger.debug("RISER: Applying enhanced HPF sweep 80Hz -> 3500Hz") + combined = self._apply_hpf_sweep(combined, start_hz=80.0, end_hz=3500.0) + + t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) + plateau_start = 0.82 + plateau_end = 0.95 + + ramp = np.zeros(num_samples, dtype=np.float32) + ramp_phase = t[t <= plateau_start] + if len(ramp_phase) > 0: + ramp_indices = t <= plateau_start + exp_ramp = np.exp(np.linspace(np.log(0.03), np.log(0.92), ramp_indices.sum())) + ramp[ramp_indices] = exp_ramp + + plateau_mask = (t > plateau_start) & (t <= plateau_end) + if np.any(plateau_mask): + ramp[plateau_mask] = np.linspace(0.92, 0.98, plateau_mask.sum()) + + final_ramp_mask = t > plateau_end + if np.any(final_ramp_mask): + ramp[final_ramp_mask] = np.linspace(0.98, 1.0, final_ramp_mask.sum()) + + ramp = ramp.reshape(-1, 1) + combined = combined * ramp + + saturation_start = int(num_samples * 0.65) + tail = combined[saturation_start:].copy() + + logger.debug("RISER: Applying progressive saturation to tail (last 35%%)") + saturation_sections = [ + (0.0, 0.3, 0.15), + (0.3, 0.6, 0.25), + (0.6, 1.0, 0.35), + ] + + for start_ratio, end_ratio, drive in saturation_sections: + sect_start = int(tail.shape[0] * start_ratio) + sect_end = int(tail.shape[0] * end_ratio) + if sect_end > sect_start: + tail[sect_start:sect_end] = self._apply_saturator(tail[sect_start:sect_end], drive=drive) + + crossfade_len = min(int(0.015 * self.sample_rate), tail.shape[0]) + if crossfade_len > 0: + fade_curve = np.sin(np.linspace(0, np.pi/2, crossfade_len, dtype=np.float32)).reshape(-1, 1) + saturated_full = self._apply_saturator(tail, drive=0.28) + tail[:crossfade_len] = tail[:crossfade_len] * (1 - fade_curve) + saturated_full[:crossfade_len] * fade_curve + + combined[saturation_start:] = tail + + combined = self._apply_fade(combined, fade_in_s=0.08, fade_out_s=0.04) + result = self._normalize(combined, peak=0.85) + + final_duration = len(result) / self.sample_rate + logger.debug("RISER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _apply_lpf_simple(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray: + """Aplica filtro low-pass simple (media movil exponencial). + + Args: + audio: Array de audio (samples, channels) + cutoff_hz: Frecuencia de corte en Hz + + Returns: + Audio filtrado + """ + audio = self._validate_audio_array(audio, context="_apply_lpf_simple") + cutoff_hz = max(20.0, min(20000.0, float(cutoff_hz))) + + # Constante de tiempo para el filtro RC + rc = 1.0 / (2.0 * 3.14159 * cutoff_hz) + dt = 1.0 / self.sample_rate + alpha = dt / (rc + dt) + + output = np.zeros_like(audio) + for ch in range(audio.shape[1]): + output[0, ch] = audio[0, ch] + for i in range(1, len(audio)): + output[i, ch] = output[i - 1, ch] + alpha * (audio[i, ch] - output[i - 1, ch]) + + return output.astype(np.float32) + + def _apply_lpf_sweep(self, audio: np.ndarray, start_hz: float = 8000.0, end_hz: float = 200.0) -> np.ndarray: + """Aplica barrido de filtro low-pass a lo largo del audio. + + Phase 1 Improvements: + - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) + - Overlap-add con 75% overlap para transiciones suaves + - Normalizacion de ventana para evitar artefactos de amplitud + - Fallback a filtro RC simple si scipy no disponible + + Args: + audio: Array de audio (samples, channels) + start_hz: Frecuencia inicial del sweep en Hz + end_hz: Frecuencia final del sweep en Hz + + Returns: + Audio con LPF sweep aplicado + """ + audio = self._validate_audio_array(audio, context="_apply_lpf_sweep") + start_hz = max(50.0, min(20000.0, float(start_hz))) + end_hz = max(20.0, min(20000.0, float(end_hz))) + + num_samples = audio.shape[0] + + # Si scipy disponible, usar Butterworth 4to orden con overlap-add + if scipy_signal is not None: + output = np.zeros_like(audio, dtype=np.float32) + + # Frames de 25ms con 75% overlap + frame_size = int(0.025 * self.sample_rate) + hop_size = frame_size // 4 # 75% overlap + num_frames = max(1, (num_samples - frame_size) // hop_size + 1) + + window = np.hanning(frame_size).astype(np.float32) + window_sum = np.zeros(num_samples, dtype=np.float32) + + for i in range(num_frames): + start_sample = i * hop_size + end_sample = min(start_sample + frame_size, num_samples) + + # Interpolacion exponencial de la frecuencia (mas musical) + progress = start_sample / num_samples + exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) + cutoff = start_hz * (end_hz / start_hz) ** exp_progress + + frame = audio[start_sample:end_sample] + actual_frame_size = frame.shape[0] + + if actual_frame_size < frame_size: + padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) + padded[:actual_frame_size] = frame + frame = padded + actual_window = window.copy() + actual_window[actual_frame_size:] = 0.0 + else: + actual_window = window + + try: + nyquist = self.sample_rate / 2.0 + normalized_cutoff = min(0.49, max(0.01, cutoff / nyquist)) + + # Butterworth 4to orden + b, a = scipy_signal.butter(4, normalized_cutoff, btype="low", output="ba") + + filtered = np.zeros_like(frame) + for ch in range(frame.shape[1]): + filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) + + windowed = filtered * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, num_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + except Exception as exc: + logger.debug("Error en LPF sweep frame %d: %s", i, exc) + windowed = frame * actual_window.reshape(-1, 1) + out_len = min(actual_frame_size, num_samples - start_sample) + output[start_sample:start_sample + out_len] += windowed[:out_len] + window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 + + # Normalizar por suma de ventanas + window_sum = np.maximum(window_sum, 1e-8) + output = output / window_sum.reshape(-1, 1) + return output.astype(np.float32) + + # Fallback: filtro RC simple por bloques + output = np.zeros_like(audio) + block_size = max(256, num_samples // 64) + num_blocks = (num_samples + block_size - 1) // block_size + + for block_idx in range(num_blocks): + start_sample = block_idx * block_size + end_sample = min(start_sample + block_size, num_samples) + + progress = start_sample / num_samples + exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) + cutoff = start_hz * (end_hz / start_hz) ** exp_progress + + block_audio = audio[start_sample:end_sample] + filtered_block = self._apply_lpf_simple(block_audio, cutoff) + output[start_sample:end_sample] = filtered_block + + return output.astype(np.float32) + + def _apply_simple_reverb(self, audio: np.ndarray, decay: float = 0.3, wet_mix: float = 0.15, delay_ms: float = 50.0) -> np.ndarray: + """Aplica reverb simple con multiples delays. + + Args: + audio: Array de audio (samples, channels) + decay: Factor de decaimiento (0.0 - 0.9) + wet_mix: Mezcla de senal procesada (0.0 - 1.0) + delay_ms: Delay base en milisegundos + + Returns: + Audio con reverb aplicado + """ + audio = self._validate_audio_array(audio, context="_apply_simple_reverb") + decay = max(0.0, min(0.9, float(decay))) + wet_mix = max(0.0, min(1.0, float(wet_mix))) + delay_ms = max(1.0, min(200.0, float(delay_ms))) + + output = np.array(audio, dtype=np.float32, copy=True) + delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) + + # Multiples delays para crear reverb mas denso + delay_times = [1.0, 1.3, 1.7, 2.1] # Proporciones del delay base + decay_factors = [decay, decay * 0.7, decay * 0.5, decay * 0.3] + + for delay_ratio, decay_factor in zip(delay_times, decay_factors): + current_delay = int(round(delay_samples * delay_ratio)) + if current_delay < audio.shape[0]: + delayed = np.zeros_like(output) + delayed[current_delay:] = output[:-current_delay] * decay_factor + output = output + delayed + + # Mezclar dry y wet + dry_mix = 1.0 - wet_mix + return (audio * dry_mix + output * wet_mix).astype(np.float32) + + def _render_downlifter(self, source_path: str, duration_s: float = 6.0, bpm: float = 128.0) -> np.ndarray: + """Renderiza efecto de downlifter profesional con LPF sweep mejorado y reverb tail extendido. + + Phase 1 Improvements: + - BPM-synced for better musical timing + - Longer reverb tail with layered decay (up to 60% of duration) + - Enhanced LPF sweep curve (15000Hz -> 60Hz for more dramatic effect) + - Added subtle noise floor for depth + - Improved grain texture with BPM-synced rhythm + - Better volume envelope with Hz-tuned amplitude curve + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + bpm: BPM del proyecto para sincronizar curvas + + Returns: + Audio procesado + """ + duration_s = max(0.1, float(duration_s)) + bpm = max(60.0, min(200.0, float(bpm or 128.0))) + logger.debug("Rendering DOWNLIFTER FX: source=%s, duration=%.1fs, bpm=%.1f", Path(source_path).name, duration_s, bpm) + + audio, _ = self._load_audio(source_path) + min_segment_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate + beat_duration = 60.0 / bpm + segment_duration = max(min_segment_duration, min(beat_duration * 3.0, duration_s / 2.5)) + if segment_duration == min_segment_duration: + logger.debug("Using minimum segment duration %.3fs for short audio in downlifter", min_segment_duration) + segment = self._extract_tail(audio, segment_duration) + stretched = self._stretch_to_length(segment, int(round(duration_s * self.sample_rate))) + + num_samples = stretched.shape[0] + + t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) + + exp_decay = np.exp(-3.5 * t) + s_curve_start = 0.55 + s_mask = (t > s_curve_start).astype(np.float32) + s_t = (t - s_curve_start) / (1.0 - s_curve_start) + s_curve = 1.0 - (3.0 * s_t**2 - 2.0 * s_t**3) + + volume_curve = exp_decay * (1.0 - s_mask) + (exp_decay * s_curve) * s_mask + volume_curve = volume_curve * 0.97 + 0.03 + volume_curve = volume_curve.reshape(-1, 1) + stretched = stretched * volume_curve + + logger.debug("DOWNLIFTER: Applying enhanced LPF sweep 15000Hz -> 60Hz") + stretched = self._apply_lpf_sweep(stretched, start_hz=15000.0, end_hz=60.0) + + grain_rate_hz = bpm / 60.0 * 4.0 + grain_period = max(16, int(round(self.sample_rate / grain_rate_hz))) + grain_envelope = np.ones(num_samples, dtype=np.float32) + grain_depth = 0.025 + + grain_start = int(num_samples * 0.45) + for i in range(grain_start, num_samples, grain_period): + grain_samples = min(grain_period, num_samples - i) + if grain_samples <= 0: + continue + phase = np.linspace(0, np.pi * 2, min(grain_samples, grain_period), dtype=np.float32) + grain_wave = (np.sin(phase) * 0.5 + 0.5) * grain_depth + progress = (i - grain_start) / max(1, num_samples - grain_start) + grain_wave *= (1.0 + progress * 0.6) + end_idx = min(i + grain_samples, num_samples) + apply_len = min(len(grain_wave), end_idx - i) + if apply_len > 0: + grain_envelope[i:i + apply_len] = grain_envelope[i:i + apply_len] * (1.0 - grain_wave[:apply_len]) + + grain_envelope = grain_envelope.reshape(-1, 1) + stretched = stretched * grain_envelope + + tail_start = int(num_samples * 0.48) + tail = stretched[tail_start:].copy() + + tail_with_reverb = self._apply_simple_reverb( + tail, + decay=0.6, + wet_mix=0.4, + delay_ms=30.0 + ) + + tail_with_reverb = self._apply_simple_reverb( + tail_with_reverb, + decay=0.45, + wet_mix=0.18, + delay_ms=65.0 + ) + + if tail_with_reverb.shape[0] > 0: + layer_depth_start = int(tail_with_reverb.shape[0] * 0.6) + depth_layer = tail_with_reverb[layer_depth_start:].copy() + if depth_layer.shape[0] > 0: + depth_layer = self._apply_simple_reverb(depth_layer, decay=0.35, wet_mix=0.12, delay_ms=100.0) + tail_with_reverb[layer_depth_start:] = depth_layer + + stretched = np.concatenate([stretched[:tail_start], tail_with_reverb], axis=0) + + fade_duration_s = min(1.4, duration_s * 0.28) + fade_samples = int(round(fade_duration_s * self.sample_rate)) + + if fade_samples > 0 and fade_samples < stretched.shape[0]: + fade_start = stretched.shape[0] - fade_samples + fade_t = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + fade_curve = np.log1p(-fade_t * 0.95 + 0.05) / np.log(0.05) + fade_curve = np.clip(fade_curve, 0.0, 1.0) + fade_curve = fade_curve ** 0.65 + stretched[fade_start:] = stretched[fade_start:] * fade_curve.reshape(-1, 1) + + stretched = self._apply_fade(stretched, fade_in_s=0.02, fade_out_s=0.0) + result = self._normalize(stretched, peak=0.82) + + final_duration = len(result) / self.sample_rate + logger.debug("DOWNLIFTER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) + return result + + def _apply_slice_window(self, audio: np.ndarray, fade_samples: int = 44) -> np.ndarray: + """Aplica ventana con fade in/out muy corto a cada slice para evitar clicks. + + Args: + audio: Array de audio (samples, channels) + fade_samples: Numero de samples para el fade (default: 44 = ~1ms a 44.1kHz) + + Returns: + Audio con ventana aplicada + """ + if audio is None or audio.size == 0: + return audio + + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + audio = audio.reshape(-1, 1) + + total = audio.shape[0] + if total <= fade_samples * 2: + # Si el slice es muy corto, aplicar ventana completa tipo Hanning + window = np.hanning(total) + return audio * window.reshape(-1, 1) + + # Crear ventana: fade in al inicio, fade out al final + window = np.ones(total, dtype=np.float32) + window[:fade_samples] = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) + window[-fade_samples:] = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) + + return audio * window.reshape(-1, 1) + + def _render_stutter(self, source_path: str, duration_s: float = 2.5) -> np.ndarray: + """Renderiza efecto de stutter con sonido mas musical y organico. + + Mejoras implementadas: + - Numero de slices dinamico segun duracion (5-9 slices) + - Posiciones no uniformes con variacion aleatoria natural + - Pitch shift hasta 1 semitono hacia el final + - Reverb en los gaps entre slices para espacialidad + - Fade windows mas cortos (~0.5ms) + - Variacion de ganancia y timing para menos mecanicidad + + Args: + source_path: Ruta al archivo fuente + duration_s: Duracion en segundos + + Returns: + Audio procesado + """ + # Validaciones defensivas + duration_s = max(0.1, float(duration_s)) + logger.debug("Rendering STUTTER FX: source=%s, duration=%.1fs", Path(source_path).name, duration_s) + + audio, _ = self._load_audio(source_path) + source = self._find_hot_slice(audio, 0.20) # Ligeramente mas largo para mas contenido + output_len = int(round(duration_s * self.sample_rate)) + + # Asegurar que output_len sea valido + output_len = max(1, output_len) + + output = np.zeros((output_len, source.shape[1]), dtype=np.float32) + output = _ensure_2d_float(output) + + # Numero dinamico de slices segun duracion (mas cortos = menos slices) + # 5 slices para <2s, hasta 9 slices para >4s + num_slices = int(5 + min(4, int(duration_s / 1.0))) + num_slices = max(5, min(9, num_slices)) + + # Generar posiciones base con curva exponencial (mas denso hacia el final) + # Esto crea un patron mas musical tipo "building up" + base_positions = [] + for i in range(num_slices): + # Curva exponencial: 0 -> 0.85 con densidad creciente + t = i / max(1, num_slices - 1) + # Funcion exponencial para agrupar mas hacia el final + pos = (t ** 1.6) * 0.85 + base_positions.append(pos) + + # Aplicar variacion aleatoria a las posiciones para sonido mas organico + # Usar hash del source_path como semilla para consistencia + seed_hash = int(hashlib.md5(source_path.encode()).hexdigest()[:8], 16) % 10000 + np.random.seed(seed_hash) + + positions = [] + for i, base_pos in enumerate(base_positions): + # Variacion de +/- 3% en posicion + variation = (np.random.random() - 0.5) * 0.06 + pos = (base_pos + variation) * duration_s + # Asegurar que no se solapen demasiado + if i > 0: + pos = max(pos, positions[-1] + 0.08) + positions.append(min(pos, duration_s - 0.1)) + + logger.debug("STUTTER: placing %d slices at positions: %s", num_slices, [round(p, 3) for p in positions]) + + # Duracion base del slice con variacion + base_slice_duration = 0.16 + + # Crear buffer de reverb para los gaps (cola de reverb corta) + reverb_tail_samples = int(0.08 * self.sample_rate) # 80ms de reverb tail + + for index, position in enumerate(positions): + start = int(round(float(position) * self.sample_rate)) + + # Variar duracion del gate: mas corto hacia el final con variacion aleatoria + gate_variation = (np.random.random() - 0.5) * 0.04 # +/- 20ms + gate_duration = base_slice_duration - (index * 0.012) + gate_variation + # Usar constante minima para slice de stutter + min_gate_duration = self._MIN_SAMPLES_FOR_SLICE / self.sample_rate + gate_duration = max(min_gate_duration, gate_duration) + if gate_duration == min_gate_duration: + logger.debug("Using minimum slice duration %.3fs for short audio", min_gate_duration) + gate_len = max(self._MIN_SAMPLES_FOR_SLICE, min(source.shape[0], int(round(gate_duration * self.sample_rate)))) + + # Extraer slice con copia + slice_audio = np.array(source[:gate_len], dtype=np.float32, copy=True) + slice_audio = _ensure_2d_float(slice_audio) + + # VALIDACION TEMPRANA: Verificar que el slice tiene contenido real + # _ensure_2d_float retorna (1,1) con zeros si esta vacio, verificamos shape + if slice_audio.shape[0] <= 1: + logger.debug("STUTTER: slice %d has invalid shape after ensure_2d_float %s, skipping", index, slice_audio.shape) + continue + + # Pitch shift mas extremo hacia el final (hasta 1 semitono = 1.0595) + # Aplicar desde el slice 3 en adelante + if index >= 3: + # Calcular pitch factor: va de 1.02 hasta ~1.06 (1 semitono) + pitch_progress = (index - 3) / max(1, num_slices - 4) + # Factor de pitch: 1.02 hasta 1.06 (casi 1 semitono) + pitch_factor = 1.02 + (pitch_progress * 0.04) + # Anadir pequena variacion aleatoria al pitch (+/- 10 cents) + pitch_variation = 1.0 + (np.random.random() - 0.5) * 0.012 + pitch_factor *= pitch_variation + + if scipy_signal is not None: + try: + pitched_len = max(1, int(len(slice_audio) / pitch_factor)) + pitched = np.zeros((pitched_len, slice_audio.shape[1]), dtype=np.float32) + for ch in range(slice_audio.shape[1]): + pitched[:, ch] = scipy_signal.resample(slice_audio[:, ch], pitched_len).astype(np.float32) + slice_audio = pitched + logger.debug("STUTTER: slice %d pitch shifted by factor %.3f", index, pitch_factor) + except Exception: + pass # Mantener slice original si falla + + # VALIDACION: Verificar que pitch shift no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after pitch shift, skipping", index) + continue + + # Aplicar ventana con fade mas corto (~0.5ms = 22 samples a 44.1kHz) + fade_samples = 22 # Reducido de 44 para transiciones mas rapidas + slice_audio = self._apply_slice_window(slice_audio, fade_samples=fade_samples) + + # VALIDACION: Verificar que window no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after window, skipping", index) + continue + + # Aplicar pequeño reverb al slice para espacialidad + # Wet mix bajo para no perder definicion + slice_audio = self._apply_short_reverb(slice_audio, decay=0.25, delay_ms=35.0) + + # VALIDACION: Verificar que reverb no produjo array vacio + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d empty after reverb, skipping", index) + continue + + end = min(output_len, start + slice_audio.shape[0]) + if end <= start: + logger.debug("STUTTER: slice %d has invalid range (start=%d, end=%d), skipping", index, start, end) + continue + + # Ajustar slice al espacio disponible + actual_len = end - start + + # VALIDACION CRITICA: Asegurar que actual_len sea al menos 1 + if actual_len <= 0: + logger.debug("STUTTER: slice %d has actual_len=%d, skipping", index, actual_len) + continue + + # Trim solo si hay suficiente contenido despues del trim + if actual_len < slice_audio.shape[0]: + # Asegurar que el trim no produzca array vacio + if actual_len >= 1: + slice_audio = slice_audio[:actual_len] + else: + logger.debug("STUTTER: slice %d would become empty after trim (actual_len=%d), skipping", index, actual_len) + continue + + # VALIDACION FINAL: Verificar que slice_audio tiene contenido antes de mezclar + if slice_audio.size == 0: + logger.debug("STUTTER: slice %d is empty before mix, skipping", index) + continue + + # Ganancia variable por posicion con variacion aleatoria + # Mas alto hacia el final con pequenas variaciones + gain_base = 0.50 + (index * 0.07) + gain_variation = (np.random.random() - 0.5) * 0.08 # +/- 4% + gain = gain_base + gain_variation + gain = max(0.3, min(0.95, gain)) # Clamp entre 0.3 y 0.95 + + # Validate shapes before mixing + valid, msg = _validate_mix_shapes(output[start:end], slice_audio) + if not valid: + logger.debug("STUTTER: skipping slice %d at %d: %s", index, start, msg) + continue + + output[start:end] += slice_audio * gain + + # Agregar reverb "ghost" en el gap despues del slice (solo si no es el ultimo) + if index < len(positions) - 1: + gap_start = end + gap_end = min(output_len, gap_start + reverb_tail_samples) + if gap_end > gap_start: + # Crear ghost reverb tail muy sutil del slice anterior + ghost_len = gap_end - gap_start + + # VALIDACION: Asegurar que ghost_len es valido + if ghost_len <= 0: + logger.debug("STUTTER: slice %d has invalid ghost_len=%d, skipping ghost", index, ghost_len) + else: + ghost_audio = np.zeros((ghost_len, source.shape[1]), dtype=np.float32) + + # Copiar la cola del slice con decaimiento exponencial + # VALIDACION: Asegurar que tail_source tiene contenido + tail_samples = min(len(slice_audio), ghost_len * 2) + if tail_samples > 0: + tail_source = slice_audio[-tail_samples:] + if tail_source.size > 0: + decay_len = min(len(tail_source), ghost_len) + # VALIDACION: Asegurar que decay_len es valido + if decay_len > 0: + decay_curve = np.exp(-4.0 * np.linspace(0, 1, decay_len)).reshape(-1, 1).astype(np.float32) + # VALIDACION: El slicing defensivo asegura que tail_source[-decay_len:] tiene contenido + if tail_source[-decay_len:].size > 0: + ghost_audio[:decay_len] = tail_source[-decay_len:] * decay_curve * 0.15 + output[gap_start:gap_start + ghost_len] += ghost_audio + else: + logger.debug("STUTTER: slice %d tail_source slice is empty, skipping ghost", index) + else: + logger.debug("STUTTER: slice %d has invalid decay_len=%d, skipping ghost", index, decay_len) + else: + logger.debug("STUTTER: slice %d tail_source is empty, skipping ghost", index) + else: + logger.debug("STUTTER: slice %d has invalid tail_samples=%d, skipping ghost", index, tail_samples) + + # Fade global mas suave + output = self._apply_fade(output, fade_in_s=0.003, fade_out_s=0.15) + result = self._normalize(output) # Usa valor unificado por defecto + + # Fallback for empty render results + if result is None or result.size == 0: + logger.warning("STUTTER: fallback to silence (empty render result)") + result = np.zeros((int(2.5 * self.sample_rate), 2), dtype=np.float32) + + final_duration = len(result) / self.sample_rate + logger.debug("STUTTER: generated %s (duration=%.1fs, slices=%d)", Path(source_path).name, final_duration, num_slices) + return result + + + def _output_path(self, source_path: str, variant_seed: int, suffix: str) -> Path: + """Genera ruta de salida unica para un archivo procesado.""" + source = Path(source_path) + digest = hashlib.sha1(f"{source.resolve()}::{variant_seed}::{suffix}".encode("utf-8")).hexdigest()[:10] + return self.output_dir / f"{source.stem}_{suffix}_{digest}.wav" + + def _analyze_source_quality(self, audio: np.ndarray, sample_rate: int, fx_type: str) -> Dict[str, Any]: + """Analyzes source audio quality for FX derivation. + + Returns quality metrics for source selection decisions. + + Args: + audio: Audio array (samples, channels) + sample_rate: Sample rate in Hz + fx_type: Type of FX to derive ('reverse', 'riser', 'downlifter', 'stutter') + + Returns: + Dict with quality metrics: spectral_content, dynamic_range, suitability_score + """ + if audio is None or audio.size == 0: + return {"spectral_content": 0.0, "dynamic_range": 0.0, "suitability_score": 0.0, "recommended": False} + + audio = self._validate_audio_array(audio, context="_analyze_source_quality") + + # Filtrar por duración (máx 45s) para evitar canciones completas + duration = audio.shape[0] / sample_rate + if duration > 45.0: + logger.debug(f"Source analysis: rejecting long audio ({duration:.1f}s > 45s)") + return {"spectral_content": 0.0, "dynamic_range": 0.0, "rms": 0.0, "suitability_score": 0.0, "recommended": False} + + mono = np.mean(np.abs(audio), axis=1) if audio.ndim > 1 else np.abs(audio) + + rms = float(np.sqrt(np.mean(mono ** 2))) if mono.size > 0 else 0.0 + peak = float(np.max(mono)) if mono.size > 0 else 0.0 + dynamic_range = peak / max(rms, 1e-10) + + spectral_content = 0.5 + if scipy_signal is not None and mono.size >= 512: + try: + freqs = np.fft.rfft(mono[:min(2048, len(mono))]) + freq_magnitude = np.abs(freqs) + if freq_magnitude.size > 10: + low_energy = np.sum(freq_magnitude[:max(1, len(freq_magnitude)//8)]) + mid_energy = np.sum(freq_magnitude[max(1, len(freq_magnitude)//8):len(freq_magnitude)//2]) + high_energy = np.sum(freq_magnitude[len(freq_magnitude)//2:]) + total = low_energy + mid_energy + high_energy + 1e-10 + high_ratio = high_energy / total + mid_ratio = mid_energy / total + spectral_content = float(0.3 + 0.5 * (high_ratio + mid_ratio * 0.5)) + except Exception: + pass + + suitability_scores = { + "reverse": min(1.0, spectral_content * 0.7 + min(1.0, dynamic_range) * 0.3), + "riser": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), + "downlifter": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), + "stutter": min(1.0, 0.3 + spectral_content * 0.4 + min(1.0, dynamic_range) * 0.3), + } + + score = suitability_scores.get(fx_type, 0.5) + recommended = score >= 0.4 and dynamic_range >= 2.0 and rms >= 0.01 + + return { + "spectral_content": round(spectral_content, 3), + "dynamic_range": round(dynamic_range, 3), + "rms": round(rms, 4), + "suitability_score": round(score, 3), + "recommended": recommended, + } + + def _build_positions(self, sections: List[Dict[str, Any]], bpm: float = 128.0) -> Dict[str, List[float]]: + """Construye posiciones de FX basandose en la estructura de secciones. + + Phase 2 Improvements: + - BPM-aware timing for musical placement + - Precise reverse placement exactly at section boundaries + - Riser ends precisely before drops for maximum impact + - Downlifter placed after drops for clean section exits + - Professional stutter placement at build peaks and drop tails + - Enhanced section type detection (intro, breakdown, peak, etc.) + - Duplicate suppression with minimum spacing + - Quality-aware source selection + + Args: + sections: Lista de secciones con kind, name, beats + bpm: BPM del proyecto para timing musical + + Returns: + Diccionario con listas de posiciones por tipo de FX + """ + reverse_positions: List[float] = [] + riser_positions: List[float] = [] + downlifter_positions: List[float] = [] + stutter_positions: List[float] = [] + + offsets = _section_offsets(sections) + beat_duration = 60.0 / max(60.0, min(200.0, bpm)) + bar_duration = beat_duration * 4.0 + + def _add_unique(positions: List[float], value: float, min_spacing: float = 2.0) -> None: + if not any(abs(p - value) < min_spacing for p in positions): + positions.append(round(max(0.0, value), 3)) + + def _section_type(section: Dict[str, Any]) -> str: + kind = str(section.get("kind", "")).lower() + name = str(section.get("name", "")).lower() + if "intro" in kind or "intro" in name: + return "intro" + if "break" in kind or "break" in name or "breakdown" in name: + return "break" + if "build" in kind or "build" in name: + return "build" + if "drop" in kind or "drop" in name: + return "drop" + if "peak" in name or "main" in name: + return "peak" + if "outro" in kind or "outro" in name: + return "outro" + if "groove" in name: + return "groove" + return kind or "unknown" + + for index, (section, start, end) in enumerate(offsets): + section_type = _section_type(section) + name = str(section.get("name", "")).lower() + span = max(1.0, end - start) + is_peak = "peak" in name or "drop b" in name or "main" in name or "peak" in section_type + is_build = section_type == "build" + is_break = section_type == "break" + is_drop = section_type == "drop" + is_outro = section_type == "outro" + is_intro = section_type == "intro" + + reverse_bar_offset = bar_duration * 1.5 + if index > 0 and is_drop: + reverse_offset = min(8.0, max(4.0, reverse_bar_offset)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=3.0) + elif index > 0 and is_break: + reverse_offset = min(6.0, max(3.0, reverse_bar_offset * 0.8)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.5) + elif index > 0 and is_build: + if index > 1: + reverse_offset = min(7.0, max(3.0, reverse_bar_offset)) + _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.0) + + if is_build: + riser_duration = min(12.0, max(4.0, span * 0.7)) + beat_duration_seconds = beat_duration + riser_quantized = (riser_duration / beat_duration_seconds) * beat_duration_seconds + riser_quantized = max(4.0, min(12.0, riser_quantized)) + riser_start = max(start, end - riser_quantized) + _add_unique(riser_positions, riser_start, min_spacing=4.0) + + stutter_offset = bar_duration * 0.5 + stutter_start = max(start, end - stutter_offset - 0.5) + _add_unique(stutter_positions, stutter_start, min_spacing=1.5) + + if is_break and not is_peak: + downlifter_offset = bar_duration * 0.25 + _add_unique(downlifter_positions, start + downlifter_offset, min_spacing=3.0) + + elif is_drop and not is_peak: + down_offset = bar_duration * 0.3 + _add_unique(downlifter_positions, start + down_offset, min_spacing=3.0) + + if is_outro: + if span > bar_duration * 2: + _add_unique(downlifter_positions, start + bar_duration, min_spacing=3.0) + outro_down_position = start + span * 0.45 + _add_unique(downlifter_positions, outro_down_position, min_spacing=2.5) + + if is_peak and span > bar_duration: + stutter_offset = min(bar_duration * 1.5, span * 0.25) + _add_unique(stutter_positions, end - stutter_offset, min_spacing=1.5) + + if span > bar_duration * 3: + peak_stutter_position = start + span * 0.55 + _add_unique(stutter_positions, peak_stutter_position, min_spacing=bar_duration) + + if is_intro and span > bar_duration * 2: + intro_reverse_offset = bar_duration * 0.75 + _add_unique(reverse_positions, start + intro_reverse_offset, min_spacing=2.5) + + return { + "reverse": sorted(set(reverse_positions)), + "riser": sorted(set(riser_positions)), + "downlifter": sorted(set(downlifter_positions)), + "stutter": sorted(set(stutter_positions)), + } + + def build_transition_layers( + self, + reference_audio_plan: Dict[str, Any], + sections: List[Dict[str, Any]], + project_bpm: float, + variant_seed: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Construye capas de transicion desde un plan de audio de referencia. + + Args: + reference_audio_plan: Plan con matches de audio + sections: Lista de secciones del proyecto + project_bpm: BPM del proyecto + variant_seed: Semilla para variacion + + Returns: + Lista de diccionarios con info de capas generadas + """ + logger.debug("build_transition_layers called: bpm=%.1f, variant_seed=%s", project_bpm, variant_seed) + + if not isinstance(reference_audio_plan, dict): + logger.debug("reference_audio_plan is not a dict, returning empty layers") + return [] + + selected = reference_audio_plan.get("matches", {}) or {} + if not isinstance(selected, dict): + logger.debug("matches is not a dict, returning empty layers") + return [] + + # Validar project_bpm + project_bpm = max(20.0, min(300.0, float(project_bpm or 120.0))) + + variant_seed = int(variant_seed or 0) + positions = self._build_positions(sections, bpm=project_bpm) + logger.debug("Calculated FX positions: reverse=%s, riser=%s, downlifter=%s, stutter=%s", + positions["reverse"], positions["riser"], positions["downlifter"], positions["stutter"]) + layers: List[Dict[str, Any]] = [] + + FX_SOURCE_PRIORITIES = { + "reverse": [ + ("crash_fx", 0.9), + ("fill_fx", 0.85), + ("atmos_fx", 0.75), + ("synth_loop", 0.65), + ("vocal_shot", 0.55), + ], + "riser": [ + ("synth_loop", 0.9), + ("vocal_loop", 0.85), + ("atmos_fx", 0.8), + ("pad", 0.6), + ], + "downlifter": [ + ("crash_fx", 0.9), + ("atmos_fx", 0.85), + ("synth_loop", 0.7), + ("fill_fx", 0.65), + ], + "stutter": [ + ("vocal_shot", 0.95), + ("vocal_loop", 0.85), + ("snare_roll", 0.8), + ("synth_peak", 0.65), + ], + } + + FX_FALLBACK_QUERIES = { + "reverse": ["crash", "cymbal", "impact"], + "riser": ["riser", "buildup", "sweep"], + "downlifter": ["atmos", "drone", "texture"], + "stutter": ["vocal", "synth", "chord", "fx"], + } + + def _find_fallback_source(fx_type: str) -> str: + """Find source directly from SampleManager when selected is empty.""" + try: + import importlib.util + PACKAGE_DIR = Path(__file__).resolve().parent.parent + sample_manager_path = PACKAGE_DIR / "MCP_Server" / "sample_manager.py" + if sample_manager_path.exists(): + spec = importlib.util.spec_from_file_location("sample_manager", sample_manager_path) + sm_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sm_mod) + manager = sm_mod.get_manager() + else: + from .sample_manager import get_manager + manager = get_manager() + if manager is None: + return "" + queries = FX_FALLBACK_QUERIES.get(fx_type, []) + for query in queries: + samples = manager.search(query=query, limit=5) + for sample in samples: + path = str(sample.path) + if Path(path).exists(): + try: + audio, sr = self._load_audio(path) + if audio is not None and audio.shape[0] > 1000: + logger.debug("Fallback source %s found for %s FX", Path(path).name, fx_type) + return path + except Exception: + continue + except Exception as e: + logger.debug("Fallback search failed for %s: %s", fx_type, e) + return "" + + def find_best_source(fx_type: str) -> str: + """Find best source for FX type based on quality and priority.""" + priorities = FX_SOURCE_PRIORITIES.get(fx_type, []) + for key, base_score in priorities: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + try: + audio, sr = self._load_audio(path) + quality = self._analyze_source_quality(audio, sr, fx_type) + if quality.get("recommended", False): + adjusted_score = base_score * quality.get("suitability_score", 0.5) + if adjusted_score >= 0.35: + logger.debug("Source %s selected for %s FX: quality=%.2f, score=%.2f", + Path(path).name, fx_type, quality.get("suitability_score", 0), adjusted_score) + return path + logger.debug("Source %s rejected for %s FX: quality=%.2f, recommended=%s", + Path(path).name, fx_type, quality.get("suitability_score", 0), quality.get("recommended")) + except Exception as e: + logger.debug("Could not analyze source %s for %s: %s", path, fx_type, e) + for key, _ in priorities: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + return path + fallback = _find_fallback_source(fx_type) + if fallback: + logger.info("Using fallback source for %s FX: %s", fx_type, Path(fallback).name) + return fallback + + def source_path(*keys: str) -> str: + for key in keys: + item = selected.get(key) + if isinstance(item, dict): + path = str(item.get("path", "") or "") + if path: + return path + return "" + + def maybe_add(name: str, path: str, output_suffix: str, color: int, volume: float, beat_positions: List[float], renderer): + if not path or not beat_positions: + logger.debug("Skipping %s: path=%s, positions=%s", name, path if path else "(empty)", beat_positions if beat_positions else "(empty)") + return + try: + logger.debug("Generating %s from %s, duration=%.1fs, positions=%s", + name, Path(path).name, 4.0 if "REVERSE" in name else (8.0 if "RISER" in name else (6.0 if "DOWNLIFTER" in name else 2.5)), beat_positions) + rendered = renderer(path) + output_path = self._output_path(path, variant_seed, output_suffix) + file_path = self._write_audio(output_path, rendered, self.sample_rate) + logger.debug("Successfully generated %s -> %s", name, Path(file_path).name) + except Exception as exc: + logger.warning("No se pudo generar %s desde %s: %s", name, Path(path).name, exc) + logger.debug("Error details for %s: type=%s, message=%s", name, type(exc).__name__, exc) + return + layers.append({ + "name": name, + "file_path": file_path, + "positions": beat_positions, + "color": color, + "volume": volume, + "source": Path(path).name, + "generated": True, + }) + + reverse_source = find_best_source("reverse") + if reverse_source and positions["reverse"]: + maybe_add( + "AUDIO RESAMPLE REVERSE FX", + reverse_source, + "reverse_fx", + 26, + 0.58, + positions["reverse"], + lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), + ) + else: + fallback_reverse = source_path("crash_fx", "fill_fx", "atmos_fx", "synth_loop", "vocal_shot") + if fallback_reverse and positions["reverse"]: + maybe_add( + "AUDIO RESAMPLE REVERSE FX", + fallback_reverse, + "reverse_fx", + 26, + 0.58, + positions["reverse"], + lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), + ) + + riser_source = find_best_source("riser") + if riser_source and positions["riser"]: + maybe_add( + "AUDIO RESAMPLE RISER", + riser_source, + "riser_fx", + 27, + 0.54, + positions["riser"], + lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), + ) + else: + fallback_riser = source_path("synth_loop", "vocal_loop", "atmos_fx", "pad") + if fallback_riser and positions["riser"]: + maybe_add( + "AUDIO RESAMPLE RISER", + fallback_riser, + "riser_fx", + 27, + 0.54, + positions["riser"], + lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), + ) + + downlifter_source = find_best_source("downlifter") + if downlifter_source and positions["downlifter"]: + maybe_add( + "AUDIO RESAMPLE DOWNLIFTER", + downlifter_source, + "downlifter_fx", + 54, + 0.50, + positions["downlifter"], + lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), + ) + else: + fallback_downlifter = source_path("crash_fx", "atmos_fx", "synth_loop", "fill_fx") + if fallback_downlifter and positions["downlifter"]: + maybe_add( + "AUDIO RESAMPLE DOWNLIFTER", + fallback_downlifter, + "downlifter_fx", + 54, + 0.50, + positions["downlifter"], + lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), + ) + + stutter_source = find_best_source("stutter") + if stutter_source and positions["stutter"]: + try: + source_audio, _ = self._load_audio(stutter_source) + min_samples = 1000 + if source_audio.shape[0] < min_samples: + logger.warning("Skipping STUTTER layer: source audio too short (%d samples, min %d)", + source_audio.shape[0], min_samples) + else: + quality = self._analyze_source_quality(source_audio, self.sample_rate, "stutter") + if quality.get("suitability_score", 0) >= 0.25: + maybe_add( + "AUDIO RESAMPLE STUTTER", + stutter_source, + "stutter_fx", + 41, + 0.56, + positions["stutter"], + lambda path: self._render_stutter(path, duration_s=2.5), + ) + else: + logger.debug("STUTTER source quality too low: %.2f", quality.get("suitability_score", 0)) + except Exception as exc: + logger.warning("Skipping STUTTER layer: failed to validate source: %s", exc) + else: + fallback_stutter = source_path("vocal_shot", "vocal_loop", "snare_roll", "synth_peak") + if fallback_stutter and positions["stutter"]: + try: + source_audio, _ = self._load_audio(fallback_stutter) + min_samples = 1000 + if source_audio.shape[0] >= min_samples: + maybe_add( + "AUDIO RESAMPLE STUTTER", + fallback_stutter, + "stutter_fx", + 41, + 0.56, + positions["stutter"], + lambda path: self._render_stutter(path, duration_s=2.5), + ) + except Exception as exc: + logger.warning("Fallback STUTTER also failed: %s", exc) + + logger.info("Created %d derived layers: %s", len(layers), [layer['name'] for layer in layers]) + return layers + + def invalidate_stale_cache(self) -> int: + """Elimina entradas de cache cuyos archivos han sido modificados. + + Este metodo verifica cada entrada en el cache y elimina aquellas + donde el archivo tiene un mtime diferente al que esta en la key. + + Nota: Con el diseno actual donde mtime es parte de la key, las + entradas stale naturalmente expiran por LRU. Este metodo es + utilitario para limpieza proactiva. + + Returns: + Numero de entradas eliminadas + """ + removed = 0 + keys_to_remove: List[str] = [] + + for key in list(self._audio_cache.keys()): + # Extraer path de la key (formato: "path::mtime_ns" o solo "path") + if "::" in key: + path_str, _ = key.rsplit("::", 1) + else: + path_str = key + + path = Path(path_str) + + # Verificar si el archivo aun existe y tiene el mismo mtime + if not path.exists(): + # Archivo eliminado, marcar para remover + keys_to_remove.append(key) + removed += 1 + continue + + try: + current_mtime_ns = path.stat().st_mtime_ns + # Reconstruir la key esperada con el mtime actual + expected_key = self._get_cache_key(path_str, current_mtime_ns) + + # Si la key actual no coincide con la esperada, el archivo cambio + if key != expected_key: + keys_to_remove.append(key) + removed += 1 + except OSError: + # Error al acceder al archivo, marcar para remover + keys_to_remove.append(key) + removed += 1 + + # Remover las entradas stale + for key in keys_to_remove: + del self._audio_cache[key] + + if removed > 0: + logger.debug("Invalidadas %d entradas de cache stale", removed) + + return removed + + def clear_cache(self) -> int: + """Limpia el cache de audio y devuelve el numero de entradas eliminadas. + + Returns: + Numero de entradas que fueron eliminadas del cache + """ + count = len(self._audio_cache) + self._audio_cache.clear() + self._cache_sizes.clear() + self._cache_total_bytes = 0 + self._cache_hits = 0 + self._cache_misses = 0 + return count + + def cache_size(self) -> int: + """Devuelve el numero de archivos en cache. + + Returns: + Numero de entradas en cache + """ + return len(self._audio_cache) + + def cache_stats(self) -> Dict[str, Any]: + """Devuelve estadisticas del cache de audio. + + Phase 1 Improvement: Metodo nuevo para monitorear rendimiento del cache. + + Returns: + Diccionario con estadisticas: entries, bytes, hits, misses, hit_rate + """ + total_requests = self._cache_hits + self._cache_misses + hit_rate = self._cache_hits / total_requests if total_requests > 0 else 0.0 + + return { + "entries": len(self._audio_cache), + "max_entries": self._CACHE_LIMIT, + "bytes": self._cache_total_bytes, + "max_bytes": self._CACHE_MAX_SIZE_BYTES, + "mb": round(self._cache_total_bytes / (1024 * 1024), 2), + "hits": self._cache_hits, + "misses": self._cache_misses, + "hit_rate": round(hit_rate, 3), + "max_age_s": self._CACHE_MAX_AGE_S, + } diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py new file mode 100644 index 0000000..213cb15 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py @@ -0,0 +1,431 @@ +""" +Enhanced Device Automation for Timbral Movement Between Sections. +This module provides expanded device automation parameters for musical variation. +""" + +# ============================================================================= +# ENHANCED SECTION DEVICE AUTOMATION - More timbral color per section +# ============================================================================= + +# Automatizacion de devices en tracks individuales por rol - ENHANCED +SECTION_DEVICE_AUTOMATION = { + # BASS - Filtros, drive y compresion dinamica + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + }, + 'Compressor': { + 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + }, + 'sub_bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, + }, + 'Utility': { + 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, + }, + }, + # PAD - Filtros envolventes con width y reverb + 'pad': { + 'Auto Filter': { + 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, + 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, + 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, + 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, + }, + }, + # ATMOS - Filtros espaciales con movement + 'atmos': { + 'Auto Filter': { + 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, + 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, + 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, + 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, + }, + }, + # FX ELEMENTS + 'reverse_fx': { + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, + }, + }, + 'riser': { + 'Auto Filter': { + 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, + 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, + }, + }, + 'impact': { + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, + 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, + }, + 'Saturator': { + 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, + }, + }, + 'drone': { + 'Auto Filter': { + 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, + 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, + }, + }, + # HATS - Filtros de brillantez con resonance y saturacion + 'hat_closed': { + 'Auto Filter': { + 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, + }, + }, + 'hat_open': { + 'Auto Filter': { + 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, + }, + }, + 'top_loop': { + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, + 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, + }, + }, + # SYNTHS + 'chords': { + 'Auto Filter': { + 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, + }, + }, + 'lead': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, + 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, + }, + }, + 'stab': { + 'Saturator': { + 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, + }, + }, + 'pluck': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, + }, + }, + 'arp': { + 'Echo': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, + 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, + }, + 'Saturator': { + 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, + }, + }, + 'counter': { + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, + }, + }, + # VOCAL + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, + 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, + }, + }, + # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) + 'kick': {}, + 'clap': {}, + 'snare_fill': {}, + 'perc': {}, + 'ride': {}, + 'tom_fill': {}, + 'crash': {}, + 'sc_trigger': {}, +} + +# ============================================================================= +# ENHANCED BUS DEVICE AUTOMATION - More drive/compression per section +# ============================================================================= + +BUS_DEVICE_AUTOMATION = { + 'drums': { + 'Compressor': { + 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, + 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, + }, + 'Limiter': { + 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10000.0, 'drop': 14000.0, 'break': 6500.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.05, 'break': 0.18, 'outro': 0.14}, + }, + }, + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, + }, + 'Compressor': { + 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, + 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5000.0, 'build': 7000.0, 'drop': 10000.0, 'break': 4500.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.08, 'drop': 0.12, 'break': 0.10, 'outro': 0.06}, + }, + }, + 'music': { + 'Compressor': { + 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, + 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, + 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 14000.0, 'break': 6000.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.05, 'drop': 0.03, 'break': 0.12, 'outro': 0.10}, + }, + 'Utility': { + 'Stereo Width': {'intro': 1.05, 'build': 1.10, 'drop': 1.12, 'break': 1.18, 'outro': 1.08}, + }, + 'Saturator': { + 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, + }, + }, + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.05, 'break': 0.15, 'outro': 0.08}, + 'Feedback': {'intro': 0.25, 'build': 0.38, 'drop': 0.28, 'break': 0.45, 'outro': 0.30}, + }, + 'Compressor': { + 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, + 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.04, 'build': 0.08, 'drop': 0.03, 'break': 0.12, 'outro': 0.06}, + 'Decay Time': {'intro': 2.0, 'build': 2.8, 'drop': 1.5, 'break': 3.5, 'outro': 2.5}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 13000.0, 'break': 7200.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.04, 'break': 0.14, 'outro': 0.08}, + }, + }, + 'fx': { + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12000.0, 'break': 5500.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.06, 'break': 0.18, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.22, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.10, 'break': 0.22, 'outro': 0.16}, + 'Decay Time': {'intro': 2.5, 'build': 3.2, 'drop': 2.0, 'break': 4.0, 'outro': 3.0}, + }, + 'Limiter': { + 'Gain': {'intro': -0.2, 'build': 0.0, 'drop': 0.2, 'break': -0.3, 'outro': -0.1}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 2.0, 'break': 0.8, 'outro': 0.6}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.10}, + }, + }, +} + +# ============================================================================= +# ENHANCED MASTER Device Automation - Section Energy Response +# ============================================================================= + +MASTER_DEVICE_AUTOMATION = { + 'Utility': { + 'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, + 'Gain': {'intro': 0.6, 'build': 0.8, 'drop': 1.0, 'break': 0.5, 'outro': 0.5}, + }, + 'Saturator': { + 'Drive': {'intro': 0.2, 'build': 0.35, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.08, 'outro': 0.12}, + }, + 'Compressor': { + 'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.70, 'break': 0.50, 'outro': 0.52}, + 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -14.0, 'break': -8.0, 'outro': -9.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, + 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.16}, + }, + 'Limiter': { + 'Gain': {'intro': 1.0, 'build': 1.2, 'drop': 1.4, 'break': 0.9, 'outro': 0.95}, + 'Ceiling': {'intro': -0.5, 'build': -0.8, 'drop': -1.0, 'break': -0.3, 'outro': -0.4}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 15000.0, 'break': 6000.0, 'outro': 7000.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.03, 'drop': 0.02, 'break': 0.08, 'outro': 0.06}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.02, 'build': 0.06, 'drop': 0.04, 'break': 0.08, 'outro': 0.04}, + 'Feedback': {'intro': 0.15, 'build': 0.28, 'drop': 0.20, 'break': 0.32, 'outro': 0.22}, + }, +} + +# Safety clamps for device parameters to prevent extreme values +DEVICE_PARAMETER_SAFETY_CLAMPS = { + 'Drive': {'min': 0.0, 'max': 6.0}, + 'Frequency': {'min': 20.0, 'max': 20000.0}, + 'Dry/Wet': {'min': 0.0, 'max': 1.0}, + 'Feedback': {'min': 0.0, 'max': 0.7}, + 'Stereo Width': {'min': 0.0, 'max': 1.3}, + 'Resonance': {'min': 0.0, 'max': 1.0}, + 'Ratio': {'min': 1.0, 'max': 20.0}, + 'Threshold': {'min': -60.0, 'max': 0.0}, + 'Attack': {'min': 0.0001, 'max': 0.5}, + 'Release': {'min': 0.001, 'max': 2.0}, + 'Gain': {'min': -1.0, 'max': 1.8}, + 'Decay Time': {'min': 0.1, 'max': 10.0}, +} + +MASTER_SAFETY_CLAMPS = { + 'Stereo Width': {'min': 0.0, 'max': 1.25}, + 'Drive': {'min': 0.0, 'max': 1.5}, + 'Ratio': {'min': 0.45, 'max': 0.9}, + 'Gain': {'min': 0.0, 'max': 1.6}, + 'Attack': {'min': 0.0001, 'max': 0.1}, + 'Ceiling': {'min': -3.0, 'max': 0.0}, +} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py new file mode 100644 index 0000000..6c7e6ec --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py @@ -0,0 +1,170 @@ +import json +import socket +from datetime import datetime + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\fx_group.txt" + +def log(msg): + timestamp = datetime.now().isoformat() + entry = f"[{timestamp}] {msg}" + print(entry) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(entry + "\n") + +class AbletonSocketClient: + def __init__(self, host="127.0.0.1", port=9877, timeout=30.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type, params=None): + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + return json.loads(line) + +def set_input_routing(client, track_index, routing_name): + result = client.send("set_track_input_routing", { + "index": track_index, + "routing_name": routing_name + }) + return result + +def main(): + log("=" * 60) + log("FX GROUP - TRANSITION FX LOADER") + log("=" * 60) + + client = AbletonSocketClient() + + RISER_TRACK = 20 + DOWNLIFTER_TRACK = 21 + CRASH_TRACK = 22 + IMPACT_TRACK = 23 + NOISE_TRACK = 24 + REVERSE_TRACK = 25 + + RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 1.wav" + DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\BBH - Primer Impacto -Downfilters 1.wav" + CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto - Crash 2.wav" + IMPACT_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto -Impact 1.wav" + NOISE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\EFX_01_Em_125.wav" + REVERSE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 4.wav" + + RISER_POSITIONS = [14, 46, 78, 110, 142, 174] + DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176] + CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192] + IMPACT_POSITIONS = [16, 48, 80, 112, 144] + NOISE_POSITIONS = [14, 46, 78, 110, 142, 174] + REVERSE_POSITIONS = [14, 30, 62, 94, 126] + + log(f"Track indices:") + log(f" RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}") + log(f" IMPACT={IMPACT_TRACK}, NOISE={NOISE_TRACK}, REVERSE={REVERSE_TRACK}") + + log("") + log("Step 1: Placing RISER samples...") + log(f" Positions: {RISER_POSITIONS}") + log(f" File: {RISER_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": RISER_TRACK, + "file_path": RISER_PATH, + "positions": RISER_POSITIONS, + "name": "RISER FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("Step 2: Placing DOWNLIFTER samples...") + log(f" Positions: {DOWNLIFTER_POSITIONS}") + log(f" File: {DOWNLIFTER_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": DOWNLIFTER_TRACK, + "file_path": DOWNLIFTER_PATH, + "positions": DOWNLIFTER_POSITIONS, + "name": "DOWNLIFTER FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("Step 3: Placing CRASH samples...") + log(f" Positions: {CRASH_POSITIONS}") + log(f" File: {CRASH_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": CRASH_TRACK, + "file_path": CRASH_PATH, + "positions": CRASH_POSITIONS, + "name": "CRASH FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("Step 4: Placing IMPACT samples...") + log(f" Positions: {IMPACT_POSITIONS}") + log(f" File: {IMPACT_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": IMPACT_TRACK, + "file_path": IMPACT_PATH, + "positions": IMPACT_POSITIONS, + "name": "IMPACT FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("Step 5: Placing NOISE SWEEP samples...") + log(f" Positions: {NOISE_POSITIONS}") + log(f" File: {NOISE_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": NOISE_TRACK, + "file_path": NOISE_PATH, + "positions": NOISE_POSITIONS, + "name": "NOISE FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("Step 6: Placing REVERSE FX samples...") + log(f" Positions: {REVERSE_POSITIONS}") + log(f" File: {REVERSE_PATH}") + result = client.send("create_arrangement_audio_pattern", { + "track_index": REVERSE_TRACK, + "file_path": REVERSE_PATH, + "positions": REVERSE_POSITIONS, + "name": "REVERSE FX" + }) + log(f" Result: {json.dumps(result, indent=2)}") + + log("") + log("=" * 60) + log("Setting input routing to 'No Input' for all FX tracks...") + log("=" * 60) + + for track_idx, track_name in [(RISER_TRACK, "RISER"), (DOWNLIFTER_TRACK, "DOWNLIFTER"), + (CRASH_TRACK, "CRASH"), (IMPACT_TRACK, "IMPACT"), + (NOISE_TRACK, "NOISE SWEEP"), (REVERSE_TRACK, "REVERSE FX")]: + result = set_input_routing(client, track_idx, "No Input") + log(f" {track_name} (track {track_idx}): {result}") + + log("") + log("=" * 60) + log("FX GROUP COMPLETE") + log("=" * 60) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py new file mode 100644 index 0000000..2eeb6a4 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py @@ -0,0 +1,4834 @@ +""" +reference_listener.py - Reference-track audio analysis and sample matching. + +Improved for Phase 4: +- Enhanced section detection (intro, verse, build, drop, break, outro) +- Better role detection per segment +- Precise one-shot vs loop classification +- Improved clap, hat, bass loop, vocal, fx detection +- Family repetition penalty system +""" + +from __future__ import annotations + +import json +import logging +import math +import random +import warnings +import gzip +import hashlib +import time +from collections import defaultdict, deque +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + import torch + import torch.nn.functional as F +except ImportError: # pragma: no cover + torch = None + F = None + +try: + import torch_directml +except ImportError: # pragma: no cover + torch_directml = None + + +logger = logging.getLogger("ReferenceListener") + +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] +KEY_PROFILES = { + 'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], + 'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], +} + +_cross_generation_reference_family_memory: Dict[str, int] = defaultdict(int) +_cross_generation_reference_path_memory: Dict[str, int] = defaultdict(int) + +# Section type definitions with characteristic energy patterns +# Enhanced with clearer energy thresholds and additional features for robust detection +SECTION_PROFILES = { + 'intro': { + 'energy_range': (0.0, 0.35), + 'onset_density': (0.0, 0.4), + 'spectral_brightness': (0.0, 0.5), + 'energy_stability': (0.4, 1.0), + 'typical_position': (0.0, 0.15), + 'min_bars': 4, + 'max_bars': 32, + }, + 'verse': { + 'energy_range': (0.25, 0.55), + 'onset_density': (0.3, 0.6), + 'spectral_brightness': (0.3, 0.6), + 'energy_stability': (0.5, 1.0), + 'typical_position': (0.1, 0.7), + 'min_bars': 8, + 'max_bars': 32, + }, + 'build': { + 'energy_range': (0.45, 0.85), + 'onset_density': (0.5, 0.9), + 'spectral_brightness': (0.5, 0.8), + 'energy_stability': (0.0, 0.6), + 'energy_slope': (0.05, 1.0), + 'typical_position': (0.15, 0.85), + 'min_bars': 4, + 'max_bars': 24, + 'rising': True, + }, + 'drop': { + 'energy_range': (0.65, 1.0), + 'onset_density': (0.5, 1.0), + 'spectral_brightness': (0.5, 1.0), + 'energy_stability': (0.5, 1.0), + 'typical_position': (0.2, 0.9), + 'min_bars': 8, + 'max_bars': 64, + }, + 'break': { + 'energy_range': (0.1, 0.45), + 'onset_density': (0.1, 0.4), + 'spectral_brightness': (0.2, 0.5), + 'energy_stability': (0.4, 1.0), + 'typical_position': (0.3, 0.7), + 'min_bars': 4, + 'max_bars': 24, + }, + 'outro': { + 'energy_range': (0.05, 0.4), + 'onset_density': (0.05, 0.5), + 'spectral_brightness': (0.1, 0.4), + 'energy_stability': (0.0, 0.6), + 'energy_slope': (-1.0, -0.02), + 'typical_position': (0.82, 1.0), + 'min_bars': 4, + 'max_bars': 32, + 'falling': True, + }, +} + +SECTION_CONFIDENCE_THRESHOLDS = { + 'high': 0.75, + 'medium': 0.55, + 'low': 0.35, + 'ambiguous': 0.20, +} + +# Spectral signatures for role detection +SPECTRAL_ROLE_SIGNATURES = { + 'kick': {'centroid_range': (50, 400), 'rolloff_range': (200, 2000), 'rms_spread': (0.4, 1.0), 'transient_score': (0.6, 1.0)}, + 'clap': {'centroid_range': (800, 4000), 'rolloff_range': (2000, 8000), 'rms_spread': (0.2, 0.7), 'transient_score': (0.7, 1.0)}, + 'hat': {'centroid_range': (4000, 12000), 'rolloff_range': (6000, 14000), 'rms_spread': (0.1, 0.4), 'transient_score': (0.5, 1.0)}, + 'bass_loop': {'centroid_range': (60, 500), 'rolloff_range': (200, 2000), 'rms_spread': (0.5, 1.0), 'periodicity': (0.6, 1.0)}, + 'vocal': {'centroid_range': (200, 3000), 'rolloff_range': (1000, 5000), 'rms_spread': (0.3, 0.8), 'harmonic_ratio': (0.4, 0.9)}, + 'fx': {'centroid_range': (1000, 8000), 'rolloff_range': (3000, 12000), 'rms_spread': (0.2, 0.9), 'spectral_flux': (0.5, 1.0)}, +} + +# Roles elegibles para variación por sección +# Estos roles pueden usar diferentes samples en diferentes secciones +SECTION_VARIATION_ROLES = [ + 'perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos' +] + +# Variaciones permitidas por tipo de sección +SECTION_VARIANTS = { + 'intro': ['sparse', 'minimal'], + 'verse': ['standard', 'sparse'], + 'build': ['building', 'dense'], + 'drop': ['full', 'peak'], + 'break': ['sparse', 'atmospheric'], + 'outro': ['fading', 'minimal'] +} + +ROLE_VECTOR_TYPES = { + 'kick': set(), + 'snare': set(), + 'hat': set(), + 'bass_loop': {'bass'}, + 'perc_loop': {'drum loop', 'top'}, + 'top_loop': {'top', 'drum loop'}, + 'synth_loop': {'synth loop', 'synth'}, + 'vocal_loop': {'vocal'}, + 'crash_fx': {'fx'}, + 'fill_fx': {'fx'}, + 'snare_roll': {'fx'}, + 'atmos_fx': {'fx', 'synth'}, + 'vocal_shot': {'vocal'}, +} + +ROLE_SEGMENT_SETTINGS = { + 'kick': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 10}, + 'snare': {'windows': {1.0, 2.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 10}, + 'hat': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 12}, + 'bass_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'perc_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'top_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, + 'synth_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'build', 'drop', 'break'}, 'top_k': 8}, + 'vocal_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop', 'break'}, 'top_k': 8}, + 'crash_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop', 'intro', 'outro'}, 'top_k': 6}, + 'fill_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'break', 'drop'}, 'top_k': 6}, + 'snare_roll': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop'}, 'top_k': 6}, + 'atmos_fx': {'windows': {4.0, 8.0}, 'section_kinds': {'intro', 'break', 'outro'}, 'top_k': 6}, + 'vocal_shot': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, +} + +ROLE_DURATION_WINDOWS = { + 'kick': (0.05, 2.5), + 'snare': (0.05, 3.0), + 'hat': (0.05, 2.0), + 'bass_loop': (0.75, 32.0), + 'perc_loop': (0.75, 32.0), + 'top_loop': (0.75, 32.0), + 'synth_loop': (0.75, 32.0), + 'vocal_loop': (0.75, 32.0), + 'crash_fx': (0.05, 12.0), + 'fill_fx': (0.15, 12.0), + 'snare_roll': (0.15, 12.0), + 'atmos_fx': (0.25, 32.0), + 'vocal_shot': (0.05, 3.5), +} + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(np.atleast_1d(value)[0]) + except Exception: + return float(default) + + +def _normalize_chroma(chroma: np.ndarray) -> np.ndarray: + chroma = np.asarray(chroma, dtype=np.float32).reshape(12) + total = float(np.sum(chroma)) + if total <= 1e-9: + return chroma + return chroma / total + + +def _adaptive_n_fft(audio_length: int, default_n_fft: int = 2048, min_n_fft: int = 512) -> int: + """Calcula n_fft adaptativo basado en la longitud del audio.""" + max_n_fft = audio_length // 2 + adaptive = max(min_n_fft, min(default_n_fft, max_n_fft)) + if adaptive < default_n_fft: + logger.debug("Using reduced n_fft=%d for short audio (len=%d)", adaptive, audio_length) + return adaptive + + +def _detect_key(chroma: np.ndarray) -> Tuple[Optional[str], float]: + chroma = _normalize_chroma(chroma) + best_key = None + best_score = -999.0 + + for mode, profile in KEY_PROFILES.items(): + profile_array = np.asarray(profile, dtype=np.float32) + for index in range(12): + score = np.corrcoef(chroma, np.roll(profile_array, index))[0, 1] + if np.isnan(score): + continue + if score > best_score: + best_score = float(score) + best_key = NOTE_NAMES[index] + ('m' if mode == 'minor' else '') + + return best_key, best_score if best_key else 0.0 + + +def _key_distance(left: Optional[str], right: Optional[str]) -> int: + if not left or not right: + return 6 + + def _index(key_name: str) -> int: + base = key_name[:-1] if key_name.endswith('m') else key_name + return NOTE_NAMES.index(base) if base in NOTE_NAMES else 0 + + return min((_index(left) - _index(right)) % 12, (_index(right) - _index(left)) % 12) + + +class SectionDetector: + """Detects structural sections from audio analysis with improved segmentation.""" + + def __init__(self, hop_length: int = 512, sr: int = 22050): + self.hop_length = hop_length + self.sr = sr + self.min_section_bars = 4 + self.max_section_bars = 64 + self.min_section_seconds = 6.0 + self.max_section_seconds = 120.0 + self.energy_smoothing_window = 2.0 + self.boundary_sensitivity = 0.65 + self.min_energy_diff_for_boundary = 0.08 + self.ambiguity_threshold = 0.25 + + def _compute_segment_features(self, rms: np.ndarray, onset: np.ndarray, + centroid: np.ndarray, start_frame: int, + end_frame: int, rms_global_max: float = None) -> Dict[str, float]: + """Compute normalized features for a segment.""" + rms_seg = rms[start_frame:end_frame] + onset_seg = onset[start_frame:end_frame] + centroid_seg = centroid[start_frame:end_frame] + + if len(rms_seg) == 0: + return {'energy': 0.0, 'onset_density': 0.0, 'brightness': 0.0, 'flux': 0.0, + 'energy_stability': 1.0, 'onset_variability': 0.0} + + rms_global_max = rms_global_max if rms_global_max is not None else float(np.max(rms)) + rms_global_max = max(rms_global_max, 0.001) + + energy = float(np.mean(rms_seg)) + onset_density = float(np.mean(onset_seg)) / 5.0 + brightness = float(np.mean(centroid_seg)) / 10000.0 + + if len(centroid_seg) > 1: + flux = float(np.mean(np.abs(np.diff(centroid_seg)))) / 2000.0 + else: + flux = 0.0 + + energy_stability = 1.0 + if len(rms_seg) > 1: + energy_cv = float(np.std(rms_seg)) / max(float(np.mean(rms_seg)), 0.001) + energy_stability = min(1.0, max(0.0, 1.0 - energy_cv * 2.0)) + + onset_variability = 0.0 + if len(onset_seg) > 1: + onset_std = float(np.std(onset_seg)) + onset_mean = max(float(np.mean(onset_seg)), 0.001) + onset_variability = min(1.0, onset_std / onset_mean) + + return { + 'energy': min(1.0, max(0.0, (energy / rms_global_max) * 1.5)), + 'onset_density': min(1.0, max(0.0, onset_density)), + 'brightness': min(1.0, max(0.0, brightness)), + 'flux': min(1.0, max(0.0, flux)), + 'energy_stability': round(energy_stability, 3), + 'onset_variability': round(onset_variability, 3) + } + + def _compute_richer_section_features( + self, + y: np.ndarray, + sr: int, + rms: np.ndarray, + onset_env: np.ndarray, + centroid: np.ndarray, + start_time: float, + end_time: float, + hop_length: int = 512, + n_fft: int = 2048 + ) -> Dict[str, float]: + """ + Compute richer per-section features for better reference matching. + + Returns energy_mean, energy_peak, energy_slope, spectral_centroid_mean, + spectral_centroid_std, onset_rate, low_energy_ratio, high_energy_ratio. + """ + duration = end_time - start_time + if duration < 1.0: + return { + 'energy_mean': 0.0, + 'energy_peak': 0.0, + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.0, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.0, + 'low_energy_ratio': 0.0, + 'high_energy_ratio': 0.0, + } + + frames_per_second = sr / hop_length + start_frame = int(start_time * frames_per_second) + end_frame = int(end_time * frames_per_second) + + start_frame = max(0, min(start_frame, len(rms) - 1)) + end_frame = max(start_frame + 1, min(end_frame, len(rms))) + + section_rms = rms[start_frame:end_frame] + section_onset = onset_env[start_frame:end_frame] + section_centroid = centroid[start_frame:end_frame] + + if len(section_rms) == 0: + return { + 'energy_mean': 0.0, + 'energy_peak': 0.0, + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.0, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.0, + 'low_energy_ratio': 0.0, + 'high_energy_ratio': 0.0, + } + + # Energy metrics (normalized 0-1) + rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 + energy_mean = float(np.mean(section_rms)) + energy_peak = float(np.max(section_rms)) + energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) + energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) + + # Energy slope (trend within section) + if len(section_rms) > 2: + x = np.arange(len(section_rms)) + slope, _ = np.polyfit(x, section_rms, 1) + energy_slope_norm = float(np.clip(slope * 100, -1.0, 1.0)) + else: + energy_slope_norm = 0.0 + + # Spectral centroid metrics + centroid_mean = float(np.mean(section_centroid)) + centroid_std = float(np.std(section_centroid)) if len(section_centroid) > 1 else 0.0 + centroid_mean_norm = min(1.0, centroid_mean / 10000.0) + centroid_std_norm = min(1.0, centroid_std / 6000.0) + + # Onset rate (onsets per second) + onset_threshold = float(np.mean(section_onset)) + float(np.std(section_onset)) * 0.5 + onset_count = int(np.sum(section_onset > onset_threshold)) + onset_rate = onset_count / max(duration, 0.1) + onset_rate_norm = min(1.0, onset_rate / 20.0) + + # Low and high energy ratios (STFT-based frequency analysis) + start_sample = int(start_time * sr) + end_sample = int(end_time * sr) + start_sample = max(0, min(start_sample, len(y) - 1)) + end_sample = max(start_sample + 512, min(end_sample, len(y))) + + try: + S = np.abs(librosa.stft(y[start_sample:end_sample], n_fft=n_fft)) + freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft) + total_energy = float(np.sum(S ** 2)) + 1e-10 + + low_mask = freqs < 300 + high_mask = freqs > 4000 + + low_energy = float(np.sum(S[low_mask, :] ** 2)) + high_energy = float(np.sum(S[high_mask, :] ** 2)) + + low_energy_ratio = min(1.0, low_energy / total_energy) + high_energy_ratio = min(1.0, high_energy / total_energy) + except Exception: + low_energy_ratio = 0.0 + high_energy_ratio = 0.0 + + return { + 'energy_mean': round(energy_mean_norm, 4), + 'energy_peak': round(energy_peak_norm, 4), + 'energy_slope': round(energy_slope_norm, 4), + 'spectral_centroid_mean': round(centroid_mean_norm, 4), + 'spectral_centroid_std': round(centroid_std_norm, 4), + 'onset_rate': round(onset_rate_norm, 4), + 'low_energy_ratio': round(low_energy_ratio, 4), + 'high_energy_ratio': round(high_energy_ratio, 4), + } + + def _compute_section_kind_confidence( + self, + kind: str, + features: Dict[str, float], + position_ratio: float, + prev_features: Optional[Dict[str, float]] + ) -> Tuple[float, List[str]]: + """ + Compute confidence score for section kind classification. + + Returns (confidence, alternatives) where: + - confidence is 0.0-1.0 with clear semantic thresholds: + - 0.75+: high confidence (section type is clear) + - 0.55-0.75: medium confidence (likely correct but could be alternative) + - 0.35-0.55: low confidence (ambiguous, check alternatives) + - <0.35: very low confidence (section may be misclassified) + - alternatives is list of 1-2 other plausible kinds + + Enhanced with energy trend, onset variability, positional context, and feature matching. + """ + energy = features.get('energy', 0.5) + onset_density = features.get('onset_density', 0.5) + onset_var = features.get('onset_variability', 0.0) + stability = features.get('energy_stability', 1.0) + brightness = features.get('brightness', 0.5) + + energy_mean = features.get('energy_mean', energy) + onset_rate = features.get('onset_rate', onset_density) + + energy_trend = features.get('energy_trend', 0.0) + if energy_trend == 0.0 and prev_features: + prev_energy = prev_features.get('energy', energy) + energy_trend = energy - prev_energy + + profile = SECTION_PROFILES.get(kind, {}) + confidence = 0.35 + alternatives = [] + + prev_energy = prev_features.get('energy', energy) if prev_features else energy + energy_rising = energy_trend > 0.08 + energy_falling = energy_trend < -0.08 + + def _match_range(value: float, range_tuple: Tuple[float, float]) -> float: + if not range_tuple: + return 0.5 + lo, hi = range_tuple + if lo <= value <= hi: + center = (lo + hi) / 2 + spread = (hi - lo) / 2 + dist_from_center = abs(value - center) + return 1.0 - (dist_from_center / (spread * 2 + 0.01)) + elif value < lo: + return max(0.0, 1.0 - (lo - value) * 2) + else: + return max(0.0, 1.0 - (value - hi) * 2) + + energy_match = _match_range(energy_mean, profile.get('energy_range', (0.0, 1.0))) + onset_match = _match_range(onset_rate, profile.get('onset_density', (0.0, 1.0))) + brightness_match = _match_range(brightness, profile.get('spectral_brightness', (0.0, 1.0))) + stability_match = _match_range(stability, profile.get('energy_stability', (0.0, 1.0))) + + pos_range = profile.get('typical_position', (0.0, 1.0)) + position_match = _match_range(position_ratio, pos_range) + + base_feature_score = (energy_match * 0.35 + onset_match * 0.25 + brightness_match * 0.15 + stability_match * 0.15 + position_match * 0.10) + + if kind == 'intro': + if prev_features is None: + confidence = 0.85 + base_feature_score * 0.15 + elif position_ratio < 0.12 and energy_mean < 0.32: + confidence = 0.78 + base_feature_score * 0.18 + elif position_ratio < 0.18 and energy_mean < 0.40: + confidence = 0.62 + base_feature_score * 0.15 + elif position_ratio < 0.22 and energy_mean < 0.45: + confidence = 0.48 + base_feature_score * 0.12 + else: + confidence = 0.30 + base_feature_score * 0.10 + if energy_mean > 0.55: + confidence -= 0.18 + if energy_rising and position_ratio > 0.1: + confidence -= 0.10 + alternatives = ['verse', 'break', 'build'] + + elif kind == 'outro': + if position_ratio > 0.90: + confidence = 0.88 + base_feature_score * 0.12 + elif position_ratio > 0.85 and energy_mean < 0.35: + confidence = 0.75 + base_feature_score * 0.15 + elif position_ratio > 0.80 and energy_mean < 0.42: + confidence = 0.58 + base_feature_score * 0.12 + else: + confidence = 0.32 + base_feature_score * 0.08 + if energy_falling: + confidence += 0.12 + if energy_mean > 0.55: + confidence -= 0.12 + alternatives = ['break', 'verse', 'build'] + + elif kind == 'drop': + if energy_mean > 0.72 and onset_rate > 0.48 and stability > 0.55: + confidence = 0.92 + (energy_mean - 0.72) * 0.3 + elif energy_mean > 0.62 and onset_rate > 0.40: + confidence = 0.78 + base_feature_score * 0.15 + elif energy_mean > 0.52 and onset_rate > 0.35: + confidence = 0.55 + base_feature_score * 0.12 + else: + confidence = 0.30 + base_feature_score * 0.08 + if 0.25 < position_ratio < 0.75: + confidence += 0.05 + if position_ratio < 0.18: + confidence -= 0.15 + alternatives = ['build', 'verse'] + + elif kind == 'build': + slope_range = profile.get('energy_slope', (0.0, 1.0)) + slope_match = _match_range(energy_trend, slope_range) if slope_range else 0.5 + + if energy_rising and 0.40 < energy_mean < 0.72: + confidence = 0.82 + slope_match * 0.15 + if onset_var > 0.25: + confidence = min(confidence + 0.08, 0.95) + elif energy_rising and 0.35 < energy_mean < 0.78: + confidence = 0.62 + slope_match * 0.18 + elif 0.35 < energy_mean < 0.72 and not energy_falling: + confidence = 0.45 + base_feature_score * 0.15 + else: + confidence = 0.28 + base_feature_score * 0.08 + if position_ratio < 0.12 or position_ratio > 0.88: + confidence -= 0.12 + alternatives = ['drop', 'verse', 'break'] + + elif kind == 'break': + if energy_mean < 0.35 and onset_rate < 0.30 and stability > 0.50: + confidence = 0.85 + base_feature_score * 0.12 + elif energy_mean < 0.42 and onset_rate < 0.38: + confidence = 0.65 + base_feature_score * 0.10 + elif energy_mean < 0.48 and onset_rate < 0.45: + confidence = 0.42 + base_feature_score * 0.08 + else: + confidence = 0.28 + base_feature_score * 0.06 + if 0.25 < position_ratio < 0.75: + confidence += 0.06 + if brightness > 0.55: + confidence -= 0.06 + alternatives = ['intro', 'outro', 'verse'] + + elif kind == 'verse': + if 0.25 < energy_mean < 0.58 and 0.25 < onset_rate < 0.65 and stability > 0.45: + confidence = 0.72 + base_feature_score * 0.15 + elif 0.28 < energy_mean < 0.55: + confidence = 0.52 + base_feature_score * 0.12 + else: + confidence = 0.35 + base_feature_score * 0.08 + if 0.15 < position_ratio < 0.75: + confidence += 0.05 + alternatives = ['build', 'drop', 'break'] + + else: + confidence = 0.40 + base_feature_score * 0.10 + alternatives = ['verse', 'drop'] + + total_sections = features.get('total_sections', 4) + if total_sections <= 2: + confidence = min(confidence * 0.90, 0.95) + elif total_sections >= 8: + pass + + confidence = max(0.15, min(0.98, confidence)) + + return round(confidence, 3), alternatives + + def _section_character_bonus( + self, + role: str, + candidate_analysis: Dict[str, Any], + section_features: Dict[str, Any] + ) -> float: + """ + Compute a character bonus for matching a candidate sample to a section. + + Returns a multiplier (1.0 = no change, max ~1.25) based on how well + the candidate's features match the section's acoustic character. + """ + if not section_features: + return 1.0 + + bonus = 1.0 + + onset_rate = float(section_features.get('onset_rate', 0.5)) + low_energy_ratio = float(section_features.get('low_energy_ratio', 0.0)) + high_energy_ratio = float(section_features.get('high_energy_ratio', 0.0)) + energy_slope = float(section_features.get('energy_slope', 0.0)) + energy_mean = float(section_features.get('energy_mean', 0.5)) + + candidate_centroid = float(candidate_analysis.get('spectral_centroid', 0.0) or 0.0) + candidate_onset = float(candidate_analysis.get('onset_mean', 0.0) or 0.0) + + role_lower = role.lower() + + # High onset rate section + high onset density candidate = bonus + if onset_rate > 0.4: + candidate_onset_norm = min(1.0, candidate_onset / 5.0) + if role_lower in {'hat', 'top_loop', 'perc_loop', 'perc'}: + if candidate_onset_norm > 0.6: + bonus = max(bonus, 1.0 + (candidate_onset_norm - 0.5) * 0.25) + + # High low-energy ratio + bass role = bonus + if low_energy_ratio > 0.4: + candidate_low_centroid = max(0.0, 1.0 - candidate_centroid / 3000.0) + if role_lower in {'bass_loop', 'sub_bass', 'bass'}: + if candidate_low_centroid > 0.5: + bonus = max(bonus, 1.0 + candidate_low_centroid * 0.15) + + # High high-energy ratio + hat/top role = bonus + if high_energy_ratio > 0.3: + candidate_high_centroid = min(1.0, candidate_centroid / 10000.0) + if role_lower in {'hat', 'top_loop', 'crash_fx'}: + if candidate_high_centroid > 0.5: + bonus = max(bonus, 1.0 + candidate_high_centroid * 0.12) + + # Building section (positive slope) + snare_roll/fill_fx = bonus + if energy_slope > 0.1: + if role_lower in {'snare_roll', 'fill_fx', 'riser'}: + bonus = max(bonus, 1.0 + energy_slope * 0.25) + + # Low energy section + atmos_fx = bonus + if energy_mean < 0.3: + if role_lower in {'atmos_fx', 'atmos', 'pad'}: + bonus = max(bonus, 1.0 + (0.3 - energy_mean) * 0.4) + + return min(1.25, max(1.0, round(bonus, 3))) + + def _get_role_section_features( + self, role: str, reference_sections: List[Dict[str, Any]], + role_segments: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """Get the most relevant section features for a given role.""" + if not reference_sections: + return {} + + role_lower = role.lower() + + preferred_kinds: Dict[str, List[str]] = { + 'kick': ['drop', 'build'], + 'snare': ['drop', 'build'], + 'hat': ['drop', 'verse'], + 'bass_loop': ['drop', 'build'], + 'sub_bass': ['drop', 'build'], + 'top_loop': ['drop', 'verse'], + 'perc_loop': ['drop', 'build'], + 'synth_loop': ['drop', 'verse'], + 'vocal_loop': ['drop', 'verse'], + 'vocal_shot': ['drop', 'verse'], + 'snare_roll': ['build', 'intro'], + 'fill_fx': ['build', 'break'], + 'riser': ['build', 'intro'], + 'crash_fx': ['drop', 'intro', 'outro'], + 'atmos_fx': ['break', 'intro', 'outro'], + 'atmos': ['break', 'intro', 'outro'], + 'pad': ['break', 'intro'], + } + + kinds = preferred_kinds.get(role_lower, ['drop']) + + for section in reference_sections: + kind = str(section.get('kind', 'drop')).lower() + if kind in kinds: + return section.get('features', {}) + + if reference_sections: + for section in reference_sections: + if section.get('kind', 'drop') == 'drop': + return section.get('features', {}) + return reference_sections[0].get('features', {}) + + return {} + + def _find_boundary_peaks(self, energy_diff: np.ndarray, onset_peaks: np.ndarray, + threshold: float, min_gap_frames: int) -> List[int]: + """Find section boundary peaks combining energy changes and onset peaks with improved detection.""" + if len(energy_diff) == 0: + return [] + + threshold_val = float(threshold) + + energy_percentile = float(np.percentile(energy_diff, 75)) if len(energy_diff) > 10 else threshold_val + onset_percentile = float(np.percentile(onset_peaks, 55)) + + candidates = [] + for i in range(len(energy_diff)): + energy_score = float(energy_diff[i]) + onset_score = float(onset_peaks[i]) + + combined_score = energy_score * 0.6 + onset_score * 0.4 + + if energy_score > threshold_val and onset_score > onset_percentile * 0.8: + candidates.append((i, combined_score, 'both')) + elif energy_score > energy_percentile and onset_score > onset_percentile * 0.5: + candidates.append((i, combined_score * 0.7, 'energy')) + elif onset_score > float(np.percentile(onset_peaks, 85)) and energy_score > threshold_val * 0.5: + candidates.append((i, combined_score * 0.6, 'onset')) + + if not candidates: + for i in range(len(energy_diff)): + if float(energy_diff[i]) > threshold_val * 0.7: + candidates.append((i, float(energy_diff[i]), 'fallback')) + + candidates.sort(key=lambda x: x[1], reverse=True) + + boundaries = [] + for idx, score, method in candidates: + is_valid = True + for existing in boundaries: + if abs(idx - existing) < min_gap_frames: + is_valid = False + break + if is_valid: + boundaries.append(idx) + + boundaries.sort() + return boundaries + + def _validate_section_progression(self, sections: List[Dict[str, Any]], + duration: float, tempo: float) -> List[Dict[str, Any]]: + """Validate and fix section progression for musical coherence.""" + if not sections: + return [{'kind': 'drop', 'start': 0.0, 'end': duration, + 'duration': duration, 'bars': max(8, int(duration * tempo / 60 / 4)), + 'kind_confidence': 0.3, 'features': {'energy': 0.5}}] + + beats_per_second = tempo / 60.0 + seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 + + result = [] + for i, section in enumerate(sections): + kind = section.get('kind', 'drop') + start = section.get('start', 0.0) + end = section.get('end', duration) + sec_duration = end - start + + estimated_bars = max(4, int(round(sec_duration / seconds_per_bar))) + if estimated_bars > self.max_section_bars: + kind = 'drop' if section.get('features', {}).get('energy', 0.5) > 0.6 else 'break' + if estimated_bars < self.min_section_bars and i > 0: + prev_section = result[-1] if result else None + if prev_section and prev_section.get('kind') == kind: + prev_section['end'] = end + prev_section['duration'] = end - prev_section['start'] + prev_section['bars'] += estimated_bars + continue + + section['bars'] = estimated_bars + section['beats'] = estimated_bars * 4 + result.append(section) + + for i, section in enumerate(result): + section['section_index'] = i + section['total_sections'] = len(result) + + return result + + def _compute_energy_transitions(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Compute energy transition direction between sections.""" + if len(sections) < 2: + return sections + + for i, section in enumerate(sections): + next_section = sections[i + 1] if i < len(sections) - 1 else None + prev_section = sections[i - 1] if i > 0 else None + + current_energy = section.get('features', {}).get('energy', 0.5) + next_energy = next_section.get('features', {}).get('energy', current_energy) if next_section else current_energy + prev_energy = prev_section.get('features', {}).get('energy', current_energy) if prev_section else current_energy + + energy_diff_next = next_energy - current_energy + energy_diff_prev = current_energy - prev_energy + + if energy_diff_next > 0.15: + section['energy_transition'] = 'rising' + elif energy_diff_next < -0.15: + section['energy_transition'] = 'falling' + else: + section['energy_transition'] = 'stable' + + section['energy_delta_next'] = round(energy_diff_next, 3) + section['energy_delta_prev'] = round(energy_diff_prev, 3) + + return sections + + def detect_sections(self, rms: np.ndarray, onset: np.ndarray, + centroid: np.ndarray, duration: float, + min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: + """Detect sections from audio features with improved segmentation and edge case handling.""" + if len(rms) == 0 or duration < min_section_seconds * 1.5: + default_bars = max(8, int(duration * 128 / 60 / 4)) if duration > 0 else 8 + return [{'kind': 'drop', 'start': 0.0, 'end': duration, 'bars': default_bars, + 'duration': duration, 'kind_confidence': 0.35, + 'confidence_level': 'low', + 'features': {'energy': 0.5, 'onset_density': 0.5}, + 'detection_method': 'fallback_short_track'}] + + hop_time = self.hop_length / self.sr + frames_per_section = max(1, int(min_section_seconds / hop_time)) + + rms_global_max = float(np.max(rms)) if len(rms) > 0 else 0.01 + kernel_size = min(len(rms), max(1, int(self.energy_smoothing_window / hop_time))) + + if kernel_size > 1: + smoothed_rms = np.convolve(rms, np.ones(kernel_size) / kernel_size, mode='same') + else: + smoothed_rms = rms + + if len(smoothed_rms) > 1: + energy_diff = np.abs(np.diff(smoothed_rms)) + if len(energy_diff) > kernel_size: + energy_diff = np.convolve(energy_diff, np.ones(kernel_size) / kernel_size, mode='same') + else: + energy_diff = np.zeros(1) + + onset_binary = (onset > np.percentile(onset, 65)).astype(float) + onset_peaks = np.convolve(onset_binary, np.ones(kernel_size) / kernel_size, mode='same') + + base_threshold = max(float(np.percentile(energy_diff, 65)), 0.001) if len(energy_diff) > 10 else 0.001 + threshold = base_threshold * self.boundary_sensitivity + + primary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, float(threshold), frames_per_section) + + secondary_threshold = float(threshold) * 0.55 + secondary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, secondary_threshold, frames_per_section // 2) + + all_boundaries = sorted(set([0] + primary_boundaries + secondary_boundaries + [len(rms) - 1])) + consolidated_boundaries = [all_boundaries[0]] + for boundary in all_boundaries[1:]: + min_gap = frames_per_section * 0.4 + if boundary - consolidated_boundaries[-1] >= min_gap: + consolidated_boundaries.append(boundary) + + if len(consolidated_boundaries) < 3 and duration > min_section_seconds * 2: + _ = smoothed_rms + n_segments = max(3, min(6, int(duration / min_section_seconds))) + segment_boundaries = [0] + for i in range(1, n_segments): + target_frame = int(i * len(rms) / n_segments) + search_range = max(1, int(len(rms) / (n_segments * 2))) + best_frame = target_frame + best_diff = float('inf') + for j in range(max(0, target_frame - search_range), min(len(energy_diff), target_frame + search_range)): + if float(energy_diff[j]) > best_diff * 0.8: + best_diff = float(energy_diff[j]) + best_frame = j + segment_boundaries.append(best_frame) + segment_boundaries.append(len(rms) - 1) + consolidated_boundaries = sorted(set(consolidated_boundaries + segment_boundaries)) + + sections = [] + prev_features = None + prev_energy_trend = None + + for i in range(len(consolidated_boundaries) - 1): + start_frame = consolidated_boundaries[i] + end_frame = consolidated_boundaries[i + 1] + + if end_frame <= start_frame: + continue + + start_time = start_frame * hop_time + end_time = end_frame * hop_time + segment_duration = end_time - start_time + + min_duration = min_section_seconds * 0.2 + if segment_duration < min_duration: + if sections: + sections[-1]['end'] = end_time + sections[-1]['duration'] = end_time - sections[-1]['start'] + sections[-1]['merged_short'] = True + continue + + max_duration = self.max_section_seconds + if segment_duration > max_duration: + mid_frame = (start_frame + end_frame) // 2 + consolidated_boundaries.insert(i + 1, mid_frame) + end_frame = mid_frame + end_time = end_frame * hop_time + segment_duration = end_time - start_time + + features = self._compute_segment_features( + rms, onset, centroid, start_frame, end_frame, rms_global_max + ) + + energy = features.get('energy', 0.5) + if prev_features: + energy_trend = energy - prev_features.get('energy', 0.5) + else: + energy_trend = 0.0 + features['energy_trend'] = round(energy_trend, 3) + + position_ratio = start_time / duration if duration > 0 else 0.0 + positional_weight = self._compute_positional_weight(position_ratio, len(consolidated_boundaries) - 1, i) + + kind = self._classify_segment_v2( + features, position_ratio, prev_features, energy_trend, prev_energy_trend + ) + + estimated_bars = max(4, int(round(segment_duration * 128 / 60 / 4))) + + sections.append({ + 'kind': kind, + 'start': round(start_time, 3), + 'end': round(end_time, 3), + 'duration': round(segment_duration, 3), + 'bars': estimated_bars, + 'features': features, + 'positional_weight': positional_weight, + }) + + prev_features = features + prev_energy_trend = energy_trend + + merged = [] + for section in sections: + if merged and merged[-1]['kind'] == section['kind'] and section['duration'] < min_section_seconds * 0.6: + merged[-1]['end'] = section['end'] + merged[-1]['duration'] = round(section['end'] - merged[-1]['start'], 3) + merged[-1]['bars'] += section.get('bars', 4) + merged_features = merged[-1].get('features', {}) + new_features = section.get('features', {}) + merged_features['energy'] = (merged_features.get('energy', 0.5) + new_features.get('energy', 0.5)) / 2 + merged[-1]['merged_with_next'] = True + else: + merged.append(section) + + merged = self._validate_section_progression(merged, duration, 128.0) + merged = self._compute_energy_transitions(merged) + + merged = self._add_confidence_levels(merged) + + if len(merged) < 2 and duration > min_section_seconds * 2: + merged = self._create_fallback_sections(duration, 128.0, rms, onset) + + return merged + + def _add_confidence_levels(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Add human-readable confidence levels to sections.""" + for section in sections: + confidence = section.get('kind_confidence', 0.5) + if confidence >= SECTION_CONFIDENCE_THRESHOLDS['high']: + section['confidence_level'] = 'high' + elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['medium']: + section['confidence_level'] = 'medium' + elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['low']: + section['confidence_level'] = 'low' + else: + section['confidence_level'] = 'ambiguous' + return sections + + def _create_fallback_sections(self, duration: float, tempo: float, + rms: np.ndarray, onset: np.ndarray) -> List[Dict[str, Any]]: + """Create fallback sections when detection fails.""" + sections = [] + beats_per_second = tempo / 60.0 + seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 + + total_bars = max(16, int(duration / seconds_per_bar)) + + if duration < 60: + sections = [ + {'kind': 'intro', 'start': 0.0, 'end': duration * 0.25, + 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.3}, 'detection_method': 'fallback'}, + {'kind': 'drop', 'start': duration * 0.25, 'end': duration * 0.75, + 'duration': duration * 0.5, 'bars': max(8, int(total_bars * 0.5)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.6}, 'detection_method': 'fallback'}, + {'kind': 'outro', 'start': duration * 0.75, 'end': duration, + 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), + 'kind_confidence': 0.35, 'confidence_level': 'low', + 'features': {'energy': 0.35}, 'detection_method': 'fallback'}, + ] + else: + n_sections = min(5, max(3, int(duration / 30))) + section_duration = duration / n_sections + + energy_profile = [] + if len(rms) > n_sections: + segment_size = len(rms) // n_sections + for i in range(n_sections): + segment_rms = rms[i * segment_size:(i + 1) * segment_size] + energy_profile.append(float(np.mean(segment_rms)) if len(segment_rms) > 0 else 0.5) + max_energy = max(energy_profile) if energy_profile else 0.5 + energy_profile = [e / max_energy for e in energy_profile] + else: + energy_profile = [0.3, 0.5, 0.7, 0.6, 0.4][:n_sections] + + kinds = ['intro', 'verse', 'build', 'drop', 'outro'] + for i in range(n_sections): + kind = kinds[i] if i < len(kinds) else 'verse' + if i == n_sections - 1: + kind = 'outro' + elif i == 0: + kind = 'intro' + elif i == n_sections - 2: + kind = 'drop' + elif energy_profile[i] > 0.6 and i > 0 and i < n_sections - 1: + kind = 'drop' + + start = i * section_duration + end = (i + 1) * section_duration if i < n_sections - 1 else duration + + sections.append({ + 'kind': kind, + 'start': round(start, 3), + 'end': round(end, 3), + 'duration': round(end - start, 3), + 'bars': max(4, int((end - start) / seconds_per_bar)), + 'kind_confidence': 0.30, + 'confidence_level': 'low', + 'features': {'energy': energy_profile[i] if i < len(energy_profile) else 0.5}, + 'detection_method': 'fallback_energy_profile', + }) + + return sections + + def _compute_positional_weight(self, position_ratio: float, total_sections: int, + section_index: int) -> float: + """Compute positional weight for section classification confidence.""" + if total_sections <= 1: + return 1.0 + + if position_ratio < 0.15: + return 1.2 + elif position_ratio > 0.85: + return 1.2 + elif 0.35 < position_ratio < 0.65: + return 0.9 + else: + return 1.0 + + def _classify_segment_v2(self, features: Dict[str, float], position_ratio: float, + prev_features: Optional[Dict[str, float]], + energy_trend: float, prev_energy_trend: Optional[float]) -> str: + """Classify segment with improved energy trend and context awareness.""" + energy = features.get('energy', 0.5) + onset = features.get('onset_density', 0.5) + brightness = features.get('brightness', 0.5) + stability = features.get('energy_stability', 1.0) + onset_var = features.get('onset_variability', 0.0) + + is_rising = energy_trend > 0.08 or (prev_energy_trend is not None and prev_energy_trend > 0.05 and energy_trend >= 0) + is_falling = energy_trend < -0.08 or (prev_energy_trend is not None and prev_energy_trend < -0.05) + + is_strong_rise = energy_trend > 0.15 + _ = energy_trend < -0.15 + + scores = {} + + if position_ratio < 0.18: + intro_energy_match = max(0, 0.5 - abs(energy - 0.22)) + intro_onset_match = max(0, 0.4 - abs(onset - 0.22)) + intro_pos_bonus = 0.65 * (0.18 - position_ratio) + intro_stability_bonus = 0.15 if stability > 0.5 else 0 + scores['intro'] = intro_energy_match + intro_onset_match + intro_pos_bonus + intro_stability_bonus + else: + scores['intro'] = -0.5 + + if position_ratio > 0.80: + outro_energy_match = max(0, 0.5 - abs(energy - 0.22)) + outro_onset_match = max(0, 0.4 - abs(onset - 0.22)) + outro_pos_bonus = 0.55 * (position_ratio - 0.80) + outro_falling_bonus = 0.25 if is_falling else (0.10 if not is_rising else -0.15) + scores['outro'] = outro_energy_match + outro_onset_match + outro_pos_bonus + outro_falling_bonus + else: + scores['outro'] = -0.2 + + if is_strong_rise and 0.38 < energy < 0.75: + scores['build'] = 0.85 + (abs(energy_trend) * 1.5) + (onset * 0.25) + elif is_rising and 0.35 < energy < 0.78: + scores['build'] = 0.55 + (abs(energy_trend) * 2.0) + (onset * 0.15) + elif 0.35 < energy < 0.72 and onset > 0.45 and position_ratio < 0.75: + scores['build'] = 0.38 + (onset * 0.25) + elif 0.38 < energy < 0.65 and onset_var > 0.2: + scores['build'] = 0.32 + (onset_var * 0.3) + else: + scores['build'] = max(0, 0.15 - abs(energy_trend) * 2) if energy_trend < 0.05 else 0.08 + + if energy > 0.68 and onset > 0.48 and stability > 0.55: + brightness_bonus = 0.12 if brightness > 0.5 else 0 + scores['drop'] = (energy - 0.50) * 1.4 + (onset - 0.40) * 0.7 + brightness_bonus + elif energy > 0.60 and onset > 0.42: + scores['drop'] = (energy - 0.50) * 1.1 + onset * 0.45 + elif energy > 0.52: + scores['drop'] = 0.35 + (energy - 0.52) * 1.5 + else: + scores['drop'] = max(-0.3, (energy - 0.45) * 2) + + if energy < 0.40 and onset < 0.32 and stability > 0.45: + scores['break'] = 0.75 + (0.40 - energy) * 0.55 + (0.32 - onset) * 0.45 + elif energy < 0.48 and onset < 0.38 and not is_rising: + scores['break'] = 0.45 + (0.48 - energy) * 0.35 + (0.38 - onset) * 0.25 + elif energy < 0.45 and brightness < 0.45: + scores['break'] = 0.35 + (0.45 - energy) * 0.3 + else: + scores['break'] = max(0, 0.08 - abs(energy - 0.35) - abs(onset - 0.32)) + + if 0.22 < energy < 0.60 and 0.22 < onset < 0.68 and stability > 0.40: + scores['verse'] = 0.55 - abs(energy - 0.42) * 1.5 - abs(onset - 0.42) * 1.2 + elif 0.28 < energy < 0.52 and not is_rising and not is_falling: + scores['verse'] = 0.38 - abs(energy - 0.40) * 1.0 + elif 0.25 < energy < 0.55: + scores['verse'] = 0.25 + else: + scores['verse'] = 0.12 + + if not scores: + return 'drop' + + best_kind, best_score = max(scores.items(), key=lambda x: x[1]) + + if best_score < 0.10: + if energy > 0.52: + return 'drop' + elif position_ratio < 0.18: + return 'intro' + elif position_ratio > 0.82: + return 'outro' + elif energy < 0.42: + return 'break' + elif is_rising: + return 'build' + else: + return 'verse' + + second_best = sorted(scores.items(), key=lambda x: x[1], reverse=True) + if len(second_best) > 1: + score_gap = second_best[0][1] - second_best[1][1] + if score_gap < 0.12: + if second_best[0][0] == 'drop' and second_best[1][0] == 'build': + if is_rising: + return 'build' + + return best_kind + + +def generate_segment_rag_summary(report: Dict[str, Any], + library_dir: Path) -> Dict[str, Any]: + """ + Genera resumen enriquecido del indexado. + + Incluye: + - Estadisticas basicas del report + - Coverage por rol + - Segmentos por archivo (avg, min, max) + - Tiempo de procesamiento estimado + - Salud del cache + """ + manifest = report.get('manifest', []) + + # Calcular estadisticas + segment_counts = [m.get('segments', 0) for m in manifest] + + # Coverage por rol + role_segments: Dict[str, int] = defaultdict(int) + for m in manifest: + for role in m.get('roles', []): + role_segments[role] += m.get('segments', 0) + + # Cache size + cache_dir = library_dir / ".segment_rag" + cache_size_bytes = sum(f.stat().st_size for f in cache_dir.glob("*.json.gz")) if cache_dir.exists() else 0 + + return { + **report, # Incluir todos los campos originales + + # Estadisticas agregadas + "summary_stats": { + "avg_segments_per_file": sum(segment_counts) / len(segment_counts) if segment_counts else 0, + "min_segments": min(segment_counts) if segment_counts else 0, + "max_segments": max(segment_counts) if segment_counts else 0, + "total_files_indexed": len(manifest), + }, + + # Coverage por rol + "role_coverage": dict(role_segments), + + # Cache info + "cache_info": { + "cache_dir": str(cache_dir), + "cache_size_bytes": cache_size_bytes, + "cache_size_mb": round(cache_size_bytes / (1024 * 1024), 2), + }, + + # Timestamp + "generated_at": time.time(), + "generated_at_iso": time.strftime('%Y-%m-%dT%H:%M:%S'), + } + + +class ReferenceAudioListener: + # Improved role patterns with more comprehensive matching + ROLE_PATTERNS = { + 'kick': ['**/*Kick*.wav', '**/*kick*.wav', '**/*KICK*.wav', '**/*Kick_*.wav', '**/*_Kick*.wav', '**/*BD*.wav', '**/*bd*.wav', '**/*bd_*.wav'], + 'snare': ['**/*Clap*Hit*.wav', '**/*Snare*.wav', '**/*snare*.wav', '**/*Clap*.wav', '**/*clap*.wav', + '**/*SNARE*.wav', '**/*CLAP*.wav', '**/*Clap_*.wav', '**/*Snare_*.wav', '**/*SD*.wav', '**/*sd*.wav'], + 'hat': ['**/*Closed Hat*.wav', '**/*Hat*.wav', '**/*hat*.wav', '**/*HAT*.wav', '**/*ClosedHat*.wav', + '**/*Open Hat*.wav', '**/*OpenHat*.wav', '**/*cym*.wav', '**/*hihat*.wav', '**/*HiHat*.wav', '**/*HH*.wav', '**/*hh_*.wav'], + 'bass_loop': ['**/*Bass Loop*.wav', '**/*Bass_Loop*.wav', '**/*bass_loop*.wav', '**/*BassLoop*.wav', + '**/*BASS LOOP*.wav', '**/*Sub*Bass*.wav', '**/*Reese*.wav', '**/*808*.wav', '**/bass/*.wav'], + 'perc_loop': ['**/*Percussion Loop*.wav', '**/*Perc_Loop*.wav', '**/*perc_loop*.wav', + '**/*PercLoop*.wav', '**/*Perc*.wav', '**/*perc*.wav', '**/*Conga*.wav', '**/perc/*.wav'], + 'top_loop': ['**/*Top Loops*.wav', '**/*Top Loop*.wav', '**/*Full Drum*.wav', '**/*top_loop*.wav', + '**/*TopLoop*.wav', '**/*Drum Loop*.wav', '**/*DrumLoop*.wav', '**/*FullDrum*.wav', '**/hat/*.wav'], + 'synth_loop': ['**/*Synth Loop*.wav', '**/*Synth_Loop*.wav', '**/*synth_loop*.wav', + '**/*SynthLoop*.wav', '**/*Synth*.wav', '**/*synth*.wav', '**/*Chord*.wav', '**/*Pad*.wav', '**/synth/*.wav'], + 'vocal_loop': ['**/*Vocal Loop*.wav', '**/*Vox*.wav', '**/*vocal_loop*.wav', '**/*VocalLoop*.wav', + '**/*Vocal*.wav', '**/*vocal*.wav', '**/*VOCAL*.wav', '**/*VoxLoop*.wav', '**/*Chopped*.wav', '**/vocal/*.wav'], + 'crash_fx': ['**/*Crash*.wav', '**/*crash*.wav', '**/*CRASH*.wav', '**/*Impact*.wav', '**/*impact*.wav', + '**/*Cymbal*.wav', '**/*cymbal*.wav', '**/fx/*.wav'], + 'fill_fx': ['**/*Fill*.wav', '**/*fill*.wav', '**/*Tom Loop*.wav', '**/*Tom*.wav', '**/*tom*.wav', + '**/*Transition*.wav', '**/*FX*.wav'], + 'snare_roll': ['**/*Snareroll*.wav', '**/*Snare Roll*.wav', '**/*snare_roll*.wav', '**/*SnareRoll*.wav', + '**/*Roll*.wav', '**/*roll*.wav', '**/*Buildup*.wav'], + 'atmos_fx': ['**/*Atmos*.wav', '**/*atmos*.wav', '**/*Drone*.wav', '**/*drone*.wav', '**/*Ambient*.wav', + '**/*Noise*.wav', '**/*noise*.wav', '**/*Texture*.wav', '**/*Pad*.wav', '**/textures/*.wav'], + 'vocal_shot': ['**/*Vocal One Shot*.wav', '**/*Vocal Importante*.wav', '**/*vocal_shot*.wav', + '**/*VocalShot*.wav', '**/*OneShot*.wav', '**/*Shot*.wav', '**/*vocal chop*.wav'], + } + + # Role bus assignments + ROLE_TO_BUS = { + 'kick': 'drums', 'snare': 'drums', 'hat': 'drums', + 'bass_loop': 'bass', + 'perc_loop': 'drums', 'top_loop': 'drums', + 'synth_loop': 'music', + 'vocal_loop': 'vocal', 'vocal_shot': 'vocal', + 'crash_fx': 'fx', 'fill_fx': 'fx', 'snare_roll': 'fx', 'atmos_fx': 'fx', + } + + # Patrones de exclusion fuerte por rol - estos NUNCA deben pasar + ROLE_EXCLUSION_PATTERNS = { + 'kick': [ + 'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix','demo', 'song', 'master', 'top loop', 'drum loop', + 'snare roll', 'fill', 'hat loop', 'vocal loop', 'complete kit','full kit', 'mixed', 'stems', 'bounce', 'preview' + ], + 'snare': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'snare roll', 'snare_roll', 'hat loop', 'kick loop', + 'top loop', 'drum loop', 'bass loop', 'complete kit', 'full kit','mixed', 'stems', 'bounce', 'preview' + ], + 'hat': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'kick loop', 'snare loop', 'bass loop', 'vocal loop', + 'complete', 'full kit', 'mixed', 'stems', 'bounce', 'preview' + ], + 'bass_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'top loop', 'vocal loop', 'vocal_loop', 'drum loop', + 'hat loop', 'snare loop', 'perc loop', 'fx loop', 'atmos','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'vocal_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'one shot', 'oneshot', 'hit', 'stab', 'drum loop', + 'bass loop', 'top loop', 'hat loop', 'kick', 'snare','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'top_loop': [ + 'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop','demo', 'song', 'master','synth loop', 'pad', 'atmos', 'riser', 'downlifter','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'fill_fx': [ + 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'loop', 'groove','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'snare_roll': [ + 'kick', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'atmos', 'pad','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'atmos_fx': [ + 'kick', 'snare', 'hat', 'clap', 'bass','full mix', 'demo', 'song', 'master', 'drum loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'synth_loop': [ + 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'drum loop', 'vocal loop', 'bass loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'crash_fx': [ + 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + 'vocal_shot': [ + 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' + ], + } + + def __init__(self, library_dir: str, cache_path: Optional[str] = None): + self.library_dir = Path(library_dir) + self.cache_path = Path(cache_path) if cache_path else self.library_dir / ".reference_audio_cache.json" + self.segment_index_dir = self.library_dir / ".segment_rag" + self.segment_index_dir.mkdir(parents=True, exist_ok=True) + self._cache: Dict[str, Any] = self._load_cache() + self.device, self.device_name = self._resolve_device() + self._recent_paths = deque(maxlen=64) # Increased from 48 + self._recent_families = deque(maxlen=32) # Increased from 24 + self._family_usage_count: Dict[str, int] = {} # Track family usage for progressive penalty + self._section_detector = SectionDetector() # New section detector + self.sample_index_path = self.library_dir / ".sample_index.json" + self.vector_store_dir = self.library_dir.parent / "vector_store" + self._sample_index_by_path = self._load_sample_index_metadata() + self._vector_store_meta_by_path, self._vector_store_meta_by_name = self._load_vector_store_metadata() + # DJ-06: Reference directory for auto-discovery + self.reference_dir = self.library_dir.parent / "reference" + self.reference_dir.mkdir(parents=True, exist_ok=True) + + def discover_reference_track(self) -> Optional[Dict[str, Any]]: + """DJ-06: Auto-discover reference tracks from librerias/reference/ directory. + Returns analysis results (BPM, key, energy) from the first found reference file. + """ + audio_extensions = {'.mp3', '.wav', '.aif', '.aiff', '.flac'} + ref_files = [] + try: + for f in self.reference_dir.iterdir(): + if f.is_file() and f.suffix.lower() in audio_extensions: + ref_files.append(f) + except Exception: + pass + + if not ref_files: + return None + + # Analyze the first (or most recent) reference file + ref_file = max(ref_files, key=lambda f: f.stat().st_mtime) + try: + analysis = self.analyze_file(str(ref_file)) + return { + "reference_file": str(ref_file), + "reference_name": ref_file.stem, + "bpm": analysis.get("tempo", 0), + "key": analysis.get("key", ""), + "key_score": analysis.get("key_score", 0), + "energy": analysis.get("rms_mean", 0), + "sections": [ + {"name": s.get("label", ""), "start": s.get("start", 0), "end": s.get("end", 0)} + for s in analysis.get("sections", []) + ], + "duration": analysis.get("duration", 0), + } + except Exception as e: + return {"error": str(e), "reference_file": str(ref_file)} + + def suggest_from_reference(self) -> Dict[str, Any]: + """DJ-06: Get generation suggestions from auto-discovered reference track. + Returns recommended BPM, key, and structure parameters. + """ + ref = self.discover_reference_track() + if ref is None: + return {"available": False, "note": "Place a reference track in librerias/reference/"} + + if "error" in ref: + return {"available": False, "error": ref["error"]} + + return { + "available": True, + "reference_name": ref.get("reference_name", ""), + "recommended_bpm": ref.get("bpm", 126), + "recommended_key": ref.get("key", "Am"), + "reference_duration": ref.get("duration", 0), + "section_count": len(ref.get("sections", [])), + "note": "Use these values with generate_track() for reference-informed generation" + } + + @staticmethod + def _name_contains_any(name: str, tokens: Tuple[str, ...]) -> bool: + return any(token in name for token in tokens) + + @staticmethod + def _name_contains_none(name: str, tokens: Tuple[str, ...]) -> bool: + return not any(token in name for token in tokens) + + def _resolve_device(self): + if torch is not None and torch_directml is not None: + try: + return torch_directml.device(), "directml" + except Exception: + pass + if torch is not None: + return torch.device("cpu"), "cpu" + return None, "numpy" + + def _load_cache(self) -> Dict[str, Any]: + if not self.cache_path.exists(): + return {} + try: + return json.loads(self.cache_path.read_text(encoding="utf-8")) + except Exception: + return {} + + def _save_cache(self) -> None: + try: + self.cache_path.write_text(json.dumps(self._cache, indent=2), encoding="utf-8") + except Exception: + pass + + def _cache_key(self, path: Path) -> str: + return str(path.resolve()).lower() + + def _fingerprint(self, path: Path) -> str: + stat = path.stat() + return f"{stat.st_size}:{stat.st_mtime_ns}" + + def _analysis_cache_key(self, path: Path, duration_limit: Optional[float] = None) -> str: + suffix = "full" if duration_limit is None else f"{float(duration_limit):.3f}" + return f"{self._cache_key(path)}|{suffix}" + + def _segment_index_cache_prefix(self, path: Path, windows: set) -> str: + path_key = hashlib.sha1(self._cache_key(path).encode("utf-8")).hexdigest()[:16] + fingerprint = hashlib.sha1(self._fingerprint(path).encode("utf-8")).hexdigest()[:12] + windows_key = "-".join(f"{float(item):.2f}" for item in sorted(float(value) for value in windows)) or "full" + return f"{path_key}__{fingerprint}__{windows_key}" + + def _segment_index_cache_path(self, path: Path, windows: set, duration_limit: float) -> Path: + prefix = self._segment_index_cache_prefix(path, windows) + duration_key = f"{float(duration_limit):.2f}" + return self.segment_index_dir / f"{prefix}__{duration_key}.json.gz" + + def _get_segment_rag_state_path(self) -> Path: + """Get the path to the segment RAG indexing state file.""" + return self.segment_index_dir / "indexing_state.json" + + def _save_segment_rag_state(self, state: Dict[str, Any]) -> None: + """Save segment RAG indexing state to disk.""" + state_path = self._get_segment_rag_state_path() + state_path.parent.mkdir(parents=True, exist_ok=True) + with open(state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + + def _load_segment_rag_state(self) -> Dict[str, Any]: + """Load segment RAG indexing state from disk.""" + state_path = self._get_segment_rag_state_path() + if not state_path.exists(): + return {} + try: + with open(state_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + logger.warning("Failed to load segment RAG state, starting fresh", exc_info=True) + return {} + + def _load_segment_bank_from_disk(self, path: Path, windows: set, duration_limit: float) -> List[Dict[str, Any]]: + cache_path = self._segment_index_cache_path(path, windows, duration_limit) + candidate_paths = [cache_path] + if not cache_path.exists(): + prefix = self._segment_index_cache_prefix(path, windows) + candidate_paths = sorted(self.segment_index_dir.glob(f"{prefix}__*.json.gz"), reverse=True) + if not candidate_paths: + return [] + try: + for candidate_path in candidate_paths: + with gzip.open(candidate_path, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + # Handle new format with metadata + if isinstance(payload, dict): + return payload.get("segments", []) or [] + # Handle old format (list of segments) + if isinstance(payload, list): + return payload + except Exception: + logger.debug("Failed to load segment cache for %s", path, exc_info=True) + return [] + + def _save_segment_bank_to_disk(self, path: Path, windows: set, duration_limit: float, bank: List[Dict[str, Any]], metadata: Optional[Dict[str, Any]] = None) -> None: + cache_path = self._segment_index_cache_path(path, windows, duration_limit) + try: + payload: Dict[str, Any] = {"segments": bank} + if metadata: + payload["metadata"] = { + "file_name": metadata.get("file_name") or path.name, + "path": metadata.get("path") or str(path), + "roles": metadata.get("roles") or [], + "windows": sorted(float(w) for w in windows) if windows else [], + "duration_limit": float(duration_limit), + "indexed_at": time.time(), + } + with gzip.open(cache_path, "wt", encoding="utf-8") as handle: + json.dump(payload, handle) + except Exception: + logger.debug("Failed to save segment cache for %s", path, exc_info=True) + + def _load_vector_store_metadata(self) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: + by_path: Dict[str, Dict[str, Any]] = {} + by_name: Dict[str, Dict[str, Any]] = {} + metadata_path = self.vector_store_dir / "metadata.json" + if not metadata_path.exists(): + return by_path, by_name + + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("No se pudo leer metadata del vector store: %s", exc) + return by_path, by_name + + for item in payload if isinstance(payload, list) else []: + if not isinstance(item, dict): + continue + file_name = str(item.get("filename", "") or "").strip().lower() + actual_path = self.library_dir / str(item.get("filename", "") or "") + if not actual_path.exists(): + actual_path = self.library_dir / Path(str(item.get("path", "") or "")).name + if not actual_path.exists(): + continue + normalized = str(actual_path.resolve()).lower() + normalized_item = dict(item) + normalized_item["resolved_path"] = str(actual_path) + by_path[normalized] = normalized_item + if file_name and file_name not in by_name: + by_name[file_name] = normalized_item + return by_path, by_name + + def _load_sample_index_metadata(self) -> Dict[str, Dict[str, Any]]: + if not self.sample_index_path.exists(): + return {} + + try: + payload = json.loads(self.sample_index_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("No se pudo leer sample index: %s", exc) + return {} + + entries = payload.get("samples", []) if isinstance(payload, dict) else [] + by_path: Dict[str, Dict[str, Any]] = {} + for item in entries if isinstance(entries, list) else []: + if not isinstance(item, dict): + continue + file_path = Path(str(item.get("path", "") or "")) + if not file_path.exists(): + continue + by_path[str(file_path.resolve()).lower()] = dict(item) + return by_path + + def _build_blocks(self, rms: np.ndarray, onset: np.ndarray, sr: int, + hop_length: int = 512, block_seconds: float = 8.0) -> List[Dict[str, float]]: + block_size = max(1, int(round(block_seconds * sr / hop_length))) + blocks: List[Dict[str, float]] = [] + for index in range(0, len(rms), block_size): + block_rms = rms[index:index + block_size] + block_onset = onset[index:index + block_size] + if len(block_rms) == 0: + continue + start = index * hop_length / sr + end = min(len(rms) * hop_length / sr, (index + block_size) * hop_length / sr) + blocks.append({ + "start": round(float(start), 3), + "end": round(float(end), 3), + "rms": round(float(np.mean(block_rms)), 6), + "onset": round(float(np.mean(block_onset)), 6), + "energy": round(float(np.mean(block_rms) * 0.65 + np.mean(block_onset) * 0.35), 6), + }) + return blocks + + def _vectorize_analysis(self, analysis: Dict[str, Any]) -> List[float]: + chroma = list(analysis.get("chroma", [0.0] * 12)) + return [ + float(analysis.get("tempo", 0.0)) / 180.0, + min(float(analysis.get("duration", 0.0)), 240.0) / 240.0, + float(analysis.get("rms_mean", 0.0)), + float(analysis.get("rms_std", 0.0)), + min(float(analysis.get("onset_mean", 0.0)), 8.0) / 8.0, + min(float(analysis.get("onset_std", 0.0)), 8.0) / 8.0, + min(float(analysis.get("spectral_centroid", 0.0)), 10000.0) / 10000.0, + min(float(analysis.get("spectral_rolloff", 0.0)), 14000.0) / 14000.0, + ] + chroma + + def _compute_audio_descriptor( + self, + y: np.ndarray, + sr: int, + tempo_hint: float = 0.0, + duration_hint: float = 0.0, + ) -> Dict[str, Any]: + y = np.asarray(y, dtype=np.float32) + if y.size == 0: + return { + "deep_vector": [0.0] * 53, + "harmonic_ratio": 0.5, + "percussive_ratio": 0.5, + "spectral_bandwidth": 0.0, + "spectral_bandwidth_std": 0.0, + "spectral_flatness": 0.0, + "spectral_flatness_std": 0.0, + "zero_crossing_rate": 0.0, + "zero_crossing_rate_std": 0.0, + "mfcc": [0.0] * 13, + "spectral_contrast": [0.0] * 7, + } + + if y.size < 512: + y = np.pad(y, (0, 512 - y.size)) + + hop_length = 256 if y.size < sr * 2 else 512 + n_fft = _adaptive_n_fft(len(y), default_n_fft=2048, min_n_fft=256) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] + flatness = librosa.feature.spectral_flatness(y=y, n_fft=n_fft, hop_length=hop_length)[0] + zcr = librosa.feature.zero_crossing_rate(y, hop_length=hop_length)[0] + try: + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + except Exception: + chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, n_mfcc=13) + contrast = librosa.feature.spectral_contrast(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + + try: + harmonic, percussive = librosa.effects.hpss(y) + total_energy = float(np.sum(np.abs(y))) or 1.0 + harmonic_ratio = float(np.sum(np.abs(harmonic)) / total_energy) + percussive_ratio = float(np.sum(np.abs(percussive)) / total_energy) + except Exception: + harmonic_ratio = 0.5 + percussive_ratio = 0.5 + + chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) + mfcc_avg = np.mean(mfcc, axis=1) + contrast_avg = np.mean(contrast, axis=1) + duration = float(duration_hint or librosa.get_duration(y=y, sr=sr)) + + deep_vector = [ + min(float(tempo_hint or 0.0), 220.0) / 220.0, + min(duration, 240.0) / 240.0, + min(float(np.mean(rms)), 1.0), + min(float(np.std(rms)), 1.0), + min(float(np.mean(onset_env)), 8.0) / 8.0, + min(float(np.std(onset_env)), 8.0) / 8.0, + min(float(np.mean(centroid)), 12000.0) / 12000.0, + min(float(np.std(centroid)), 6000.0) / 6000.0, + min(float(np.mean(rolloff)), 16000.0) / 16000.0, + min(float(np.std(rolloff)), 8000.0) / 8000.0, + min(float(np.mean(bandwidth)), 8000.0) / 8000.0, + min(float(np.std(bandwidth)), 4000.0) / 4000.0, + min(float(np.mean(flatness)), 1.0), + min(float(np.std(flatness)), 1.0), + min(float(np.mean(zcr)), 1.0), + min(float(np.std(zcr)), 1.0), + min(max(harmonic_ratio, 0.0), 1.0), + min(max(percussive_ratio, 0.0), 1.0), + ] + [float(item) for item in chroma_avg.tolist()] \ + + [float(np.clip(item / 100.0, -1.0, 1.0)) for item in mfcc_avg.tolist()] \ + + [min(float(item), 80.0) / 80.0 for item in contrast_avg.tolist()] + + return { + "deep_vector": [round(float(item), 6) for item in deep_vector], + "harmonic_ratio": round(float(harmonic_ratio), 6), + "percussive_ratio": round(float(percussive_ratio), 6), + "spectral_bandwidth": round(float(np.mean(bandwidth)), 3), + "spectral_bandwidth_std": round(float(np.std(bandwidth)), 3), + "spectral_flatness": round(float(np.mean(flatness)), 6), + "spectral_flatness_std": round(float(np.std(flatness)), 6), + "zero_crossing_rate": round(float(np.mean(zcr)), 6), + "zero_crossing_rate_std": round(float(np.std(zcr)), 6), + "mfcc": [round(float(item), 6) for item in mfcc_avg.tolist()], + "spectral_contrast": [round(float(item), 6) for item in contrast_avg.tolist()], + } + + def _section_kind_at_time(self, sections: List[Dict[str, Any]], seconds: float) -> str: + for section in sections: + start = float(section.get("start", 0.0) or 0.0) + end = float(section.get("end", start) or start) + if start <= seconds < end: + return str(section.get("kind", "verse") or "verse").lower() + return str(sections[-1].get("kind", "verse") if sections else "verse").lower() + + def _build_reference_segment_bank( + self, + reference_path: str, + reference: Dict[str, Any], + sections: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + path = Path(reference_path) + fingerprint = self._fingerprint(path) + cache_key = f"segments::{self._cache_key(path)}::{fingerprint}" + cached = self._cache.get(cache_key) + if isinstance(cached, list) and cached: + return cached + + y, sr = librosa.load(str(path), sr=22050, mono=True) + duration = float(librosa.get_duration(y=y, sr=sr)) + tempo = float(reference.get("tempo", 0.0) or 0.0) + bank: List[Dict[str, Any]] = [] + + for window_seconds in (1.0, 2.0, 4.0, 8.0): + hop_seconds = max(0.25, window_seconds / 2.0) + cursor = 0.0 + while cursor + 0.25 <= duration: + end = min(duration, cursor + window_seconds) + start_sample = int(cursor * sr) + end_sample = max(start_sample + 256, int(end * sr)) + segment_audio = y[start_sample:end_sample] + if segment_audio.size < 256: + cursor += hop_seconds + continue + descriptor = self._compute_audio_descriptor( + segment_audio, + sr, + tempo_hint=tempo, + duration_hint=end - cursor, + ) + midpoint = cursor + ((end - cursor) / 2.0) + bank.append({ + "start": round(float(cursor), 3), + "end": round(float(end), 3), + "window_seconds": round(float(end - cursor), 3), + "kind": self._section_kind_at_time(sections, midpoint), + "vector": descriptor.get("deep_vector", []), + "rms_mean": descriptor.get("deep_vector", [0.0, 0.0, 0.0])[2] if descriptor.get("deep_vector") else 0.0, + "onset_mean": descriptor.get("deep_vector", [0.0] * 5)[4] if descriptor.get("deep_vector") else 0.0, + "spectral_centroid": round(float(descriptor.get("deep_vector", [0.0] * 7)[6] * 12000.0), 3) if descriptor.get("deep_vector") else 0.0, + "spectral_rolloff": round(float(descriptor.get("deep_vector", [0.0] * 9)[8] * 16000.0), 3) if descriptor.get("deep_vector") else 0.0, + "harmonic_ratio": descriptor.get("harmonic_ratio", 0.5), + "percussive_ratio": descriptor.get("percussive_ratio", 0.5), + "spectral_flatness": descriptor.get("spectral_flatness", 0.0), + "zero_crossing_rate": descriptor.get("zero_crossing_rate", 0.0), + }) + cursor += hop_seconds + + self._cache[cache_key] = bank + self._save_cache() + return bank + + def _build_candidate_segment_bank( + self, + candidate_path: str, + windows: set, + duration_limit: float = 32.0, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + path = Path(candidate_path) + if not path.exists(): + return [] + + fingerprint = self._fingerprint(path) + windows_key = ",".join(str(item) for item in sorted(float(value) for value in windows)) or "full" + cache_key = f"candidate_segments::{self._cache_key(path)}::{fingerprint}::{windows_key}::{float(duration_limit):.3f}" + cached = self._cache.get(cache_key) + if isinstance(cached, list) and cached: + return cached + disk_cached = self._load_segment_bank_from_disk(path, windows, duration_limit) + if disk_cached: + self._cache[cache_key] = disk_cached + return disk_cached + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) + + file_duration = float(librosa.get_duration(y=y, sr=sr)) + bank: List[Dict[str, Any]] = [] + + for window_seconds in sorted(float(value) for value in windows if float(value) > 0.0): + if file_duration <= 0.0: + continue + hop_seconds = max(0.25, window_seconds / 2.0) + cursor = 0.0 + while cursor + 0.25 <= file_duration: + end = min(file_duration, cursor + window_seconds) + start_sample = int(cursor * sr) + end_sample = max(start_sample + 256, int(end * sr)) + segment_audio = y[start_sample:end_sample] + if segment_audio.size < 256: + cursor += hop_seconds + continue + descriptor = self._compute_audio_descriptor( + segment_audio, + sr, + duration_hint=end - cursor, + ) + bank.append({ + "start": round(float(cursor), 3), + "end": round(float(end), 3), + "window_seconds": round(float(end - cursor), 3), + "vector": descriptor.get("deep_vector", []), + }) + cursor += hop_seconds + + self._cache[cache_key] = bank + self._save_segment_bank_to_disk(path, windows, duration_limit, bank, metadata=metadata) + self._save_cache() + return bank + + def analyze_file(self, file_path: str, duration_limit: Optional[float] = None) -> Dict[str, Any]: + if librosa is None: + raise RuntimeError("librosa no está disponible") + + path = Path(file_path) + cache_key = self._analysis_cache_key(path, duration_limit) + legacy_key = self._cache_key(path) + fingerprint = self._fingerprint(path) + cached = self._cache.get(cache_key) + if not isinstance(cached, dict) and duration_limit is None: + cached = self._cache.get(legacy_key) + if isinstance(cached, dict) and cached.get("fingerprint") == fingerprint: + return dict(cached["analysis"]) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) + hop_length = 512 + n_fft = _adaptive_n_fft(len(y)) + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] + rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft)[0] + try: + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + except Exception: + chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) + chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) + key, key_score = _detect_key(chroma_avg) + tempo = _safe_float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median)) + + analysis = { + "path": str(path), + "file_name": path.name, + "duration": round(float(librosa.get_duration(y=y, sr=sr)), 3), + "tempo": round(float(tempo), 3), + "key": key, + "key_confidence": round(float(key_score), 6), + "rms_mean": round(float(np.mean(rms)), 6), + "rms_std": round(float(np.std(rms)), 6), + "onset_mean": round(float(np.mean(onset_env)), 6), + "onset_std": round(float(np.std(onset_env)), 6), + "spectral_centroid": round(float(np.mean(centroid)), 3), + "spectral_rolloff": round(float(np.mean(rolloff)), 3), + "chroma": [round(float(item), 6) for item in chroma_avg.tolist()], + "blocks": self._build_blocks(rms, onset_env, sr, hop_length=hop_length), + } + analysis["vector"] = self._vectorize_analysis(analysis) + analysis.update(self._compute_audio_descriptor( + y, + sr, + tempo_hint=float(analysis.get("tempo", 0.0) or 0.0), + duration_hint=float(analysis.get("duration", 0.0) or 0.0), + )) + + self._cache[cache_key] = { + "fingerprint": fingerprint, + "analysis": analysis, + } + if duration_limit is None: + self._cache[legacy_key] = self._cache[cache_key] + self._save_cache() + return dict(analysis) + + def analyze_reference(self, reference_path: str) -> Dict[str, Any]: + analysis = self.analyze_file(reference_path) + energies = [float(block.get("energy", 0.0)) for block in analysis.get("blocks", [])] + if energies: + max_energy = max(energies) or 1.0 + for block in analysis["blocks"]: + block["energy_norm"] = round(float(block["energy"]) / max_energy, 6) + analysis["device"] = self.device_name + return analysis + + def _is_excluded_full_track(self, path: Path, sample_meta: Optional[Dict[str, Any]], vector_meta: Optional[Dict[str, Any]]) -> bool: + vector_type = str((vector_meta or {}).get("type", "") or "").lower() + if vector_type == "full_track": + return True + + duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) + category = str((sample_meta or {}).get("category", "") or "").lower() + if path.suffix.lower() == ".mp3" and duration_estimate >= 45.0: + return True + + if path.suffix.lower() == ".mp3" and path.exists(): + size_bytes = int((vector_meta or {}).get("size_bytes", (sample_meta or {}).get("size", 0)) or 0) + if size_bytes >= 5_000_000 and category in {"unknown", "loop"}: + return True + return False + + def _duration_estimate( + self, + path: Path, + sample_meta: Optional[Dict[str, Any]], + vector_meta: Optional[Dict[str, Any]], + ) -> float: + duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) + if duration_estimate > 0.0: + return duration_estimate + sample_duration = float((sample_meta or {}).get("duration", 0.0) or 0.0) + if sample_duration > 0.0: + return sample_duration + sample_size = int((sample_meta or {}).get("size", 0) or 0) + if sample_size > 0: + return min(32.0, max(0.1, sample_size / 176400.0)) + try: + return min(32.0, max(0.1, path.stat().st_size / 176400.0)) + except Exception: + return 0.0 + + def _catalog_role_match( + self, + role: str, + path: Path, + sample_meta: Optional[Dict[str, Any]], + vector_meta: Optional[Dict[str, Any]], + ) -> bool: + if self._is_excluded_full_track(path, sample_meta, vector_meta): + return False + + name = path.name + stem = path.stem.lower() + name_match = self._matches_role_name(role, name) + + category = str((sample_meta or {}).get("category", "") or "").lower() + vector_type = str((vector_meta or {}).get("type", "") or "").lower() + duration_estimate = self._duration_estimate(path, sample_meta, vector_meta) + + role_categories = { + "kick": {"kick"}, + "snare": {"snare", "clap"}, + "hat": {"hat"}, + "bass_loop": {"bass"}, + "perc_loop": {"perc"}, + "top_loop": {"loop"}, + "synth_loop": {"synth"}, + "vocal_loop": {"vocal"}, + "crash_fx": {"fx"}, + "fill_fx": {"fx"}, + "snare_roll": {"fx"}, + "atmos_fx": {"fx", "synth"}, + "vocal_shot": {"vocal"}, + } + role_types = ROLE_VECTOR_TYPES.get(role, set()) + min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role, (0.0, 999.0)) + duration_ok = duration_estimate <= 0.0 or (min_dur <= duration_estimate <= max_dur) + loopish_name = self._name_contains_any(stem, ("loop", "groove", "full drum", "full mix", "drum loop", "top loop")) + + if name_match: + return duration_ok + if role in {"kick", "snare", "hat"}: + return bool(category and category in role_categories.get(role, set()) and duration_ok) + if role == "bass_loop": + if category == "bass" and duration_ok: + return True + if vector_type and vector_type in role_types and duration_ok and self._name_contains_none(stem, ("drum loop", "full mix", "top loop", "vocal")): + return True + return False + if role == "perc_loop": + if category == "perc" and duration_ok and loopish_name: + return True + if vector_type and vector_type in role_types and duration_ok and loopish_name: + return True + return False + if role == "top_loop": + if category == "loop" and duration_ok and loopish_name and self._name_contains_none(stem, ("bass loop", "vocal", "synth loop")): + return True + if vector_type and vector_type in role_types and duration_ok and loopish_name: + return True + return False + if role == "synth_loop": + synthish_name = self._name_contains_any(stem, ("synth", "lead", "hook", "pluck", "pad", "chord", "arp", "melod")) + if category == "synth" and duration_ok and synthish_name: + return True + if vector_type and vector_type in role_types and duration_ok and synthish_name: + return True + return False + if role == "vocal_loop": + vocalish_loop = self._name_contains_any(stem, ("vocal loop", "vox", "acapella", "chant", "phrase", "vocal")) + if category == "vocal" and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): + return True + if vector_type and vector_type in role_types and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): + return True + return False + if role == "crash_fx": + return False + if role == "fill_fx": + if category == "fx" and duration_ok and self._name_contains_any(stem, ("fill", "transition", "tom loop", "drum fill", "break fill")): + return True + return False + if role == "snare_roll": + if category == "fx" and duration_ok and self._name_contains_any(stem, ("snareroll", "snare roll", "roll", "buildup")): + return True + return False + if role == "atmos_fx": + atmosish_name = self._name_contains_any(stem, ("atmos", "drone", "ambient", "noise", "texture", "downfilter", "sweep", "wash")) + if category in role_categories.get(role, set()) and duration_ok and atmosish_name: + return True + if vector_type and vector_type in role_types and duration_ok and atmosish_name: + return True + return False + if role == "vocal_shot": + if category == "vocal" and duration_ok and duration_estimate <= 3.0: + return True + if vector_type in role_types and duration_ok and duration_estimate <= 3.0: + return True + return False + return False + + def prewarm_library_matching_cache( + self, + roles: Optional[List[str]] = None, + max_files: Optional[int] = None, + duration_limit: float = 32.0, + ) -> Dict[str, Any]: + target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] + assets = self._list_assets() + windows_by_role = { + role: set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + for role in target_roles + } + + files: Dict[str, Tuple[Path, set]] = {} + for role in target_roles: + for file_path in assets.get(role, []): + normalized = str(file_path.resolve()).lower() + if normalized not in files: + files[normalized] = (file_path, set()) + files[normalized][1].update(windows_by_role.get(role, set())) + + ordered_files = list(files.values()) + if max_files is not None: + ordered_files = ordered_files[: max(0, int(max_files))] + + analyzed = 0 + segmented = 0 + errors = 0 + for file_path, windows in ordered_files: + try: + analysis = self.analyze_file(str(file_path), duration_limit=duration_limit) + analyzed += 1 + if float(analysis.get("duration", 0.0) or 0.0) > max(windows or {4.0}) * 1.5: + self._build_candidate_segment_bank(str(file_path), windows or {4.0}, duration_limit=duration_limit) + segmented += 1 + except Exception: + errors += 1 + + return { + "roles": target_roles, + "files_considered": len(ordered_files), + "analyzed": analyzed, + "segmented": segmented, + "errors": errors, + "cache_path": str(self.cache_path), + "device": self.device_name, + } + + def build_segment_rag_index( + self, + roles: Optional[List[str]] = None, + max_files: Optional[int] = None, + duration_limit: float = 32.0, + force: bool = False, + offset: int = 0, + batch_size: Optional[int] = None, + resume: bool = False, + ) -> Dict[str, Any]: + target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] + assets = self._list_assets() + files: Dict[str, Dict[str, Any]] = {} + + for role in target_roles: + for file_path in assets.get(role, []): + normalized = str(file_path.resolve()).lower() + if normalized not in files: + files[normalized] = { + "path": file_path, + "roles": set(), + "windows": set(), + } + files[normalized]["roles"].add(role) + files[normalized]["windows"].update(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + + ordered_files = sorted( + files.values(), + key=lambda item: ( + -len(item["roles"]), + -sum(float(value) for value in item["windows"]), + item["path"].name.lower(), + ), + ) + + state = self._load_segment_rag_state() + indexed_entries = dict(state.get("indexed_entries", {}) or {}) + if resume: + indexed_paths = set(state.get("indexed_paths", []) or []) + if indexed_paths: + before_resume = len(ordered_files) + ordered_files = [ + entry for entry in ordered_files + if str(entry["path"].resolve()).lower() not in indexed_paths + ] + logger.info( + "Resume mode: skipped %d already indexed files, %d remaining", + before_resume - len(ordered_files), + len(ordered_files), + ) + + total_available = len(ordered_files) + if offset > 0: + ordered_files = ordered_files[offset:] + + limit = batch_size if batch_size is not None else max_files + if limit is not None: + ordered_files = ordered_files[: max(0, int(limit))] + + files_remaining = max(0, total_available - offset - len(ordered_files)) + + built = 0 + reused = 0 + skipped = 0 + errors = 0 + total_segments = 0 + manifest: List[Dict[str, Any]] = [] + + for entry in ordered_files: + path = entry["path"] + windows = entry["windows"] or {4.0} + normalized = str(path.resolve()).lower() + sample_meta = self._sample_index_by_path.get(normalized) + vector_meta = self._vector_store_meta_by_path.get(normalized) + estimated_duration = self._duration_estimate(path, sample_meta, vector_meta) + effective_duration_limit = min(max(estimated_duration, 0.5), duration_limit) if estimated_duration > 0.0 else duration_limit + disk_cached = self._load_segment_bank_from_disk(path, windows, effective_duration_limit) + cache_prefix = self._segment_index_cache_prefix(path, windows) + if disk_cached and not force: + reused += 1 + total_segments += len(disk_cached) + entry_report = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + "segments": len(disk_cached), + "cached": True, + "cache_prefix": cache_prefix, + } + manifest.append(entry_report) + indexed_entries[normalized] = entry_report + continue + try: + analysis = self.analyze_file(str(path), duration_limit=duration_limit) + duration = float(analysis.get("duration", 0.0) or 0.0) + if duration < 0.5: + skipped += 1 + continue + segment_metadata = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + } + bank = self._build_candidate_segment_bank(str(path), windows, duration_limit=min(max(duration, 0.5), duration_limit), metadata=segment_metadata) + built += 1 + total_segments += len(bank) + entry_report = { + "file_name": path.name, + "path": str(path), + "roles": sorted(entry["roles"]), + "segments": len(bank), + "cached": False, + "cache_prefix": cache_prefix, + } + manifest.append(entry_report) + indexed_entries[normalized] = entry_report + + if (built + reused) % 10 == 0: + periodic_state = { + "indexed_files": [item["file_name"] for item in indexed_entries.values()], + "indexed_paths": list(indexed_entries.keys()), + "indexed_entries": indexed_entries, + "last_offset": offset + (built + reused), + "total_processed": len(indexed_entries), + "timestamp": time.time(), + } + self._save_segment_rag_state(periodic_state) + logger.debug("Saved segment RAG state after %d processed files", built + reused) + except Exception: + errors += 1 + logger.debug("Failed to build segment index for %s", path, exc_info=True) + + final_state = { + "indexed_files": [item["file_name"] for item in indexed_entries.values()], + "indexed_paths": list(indexed_entries.keys()), + "indexed_entries": indexed_entries, + "last_offset": offset + (built + reused), + "total_processed": len(indexed_entries), + "timestamp": time.time(), + "complete": files_remaining == 0, + } + self._save_segment_rag_state(final_state) + + return { + "roles": target_roles, + "files_targeted": len(ordered_files), + "total_available": total_available, + "built": built, + "reused": reused, + "skipped": skipped, + "errors": errors, + "total_segments": total_segments, + "segment_index_dir": str(self.segment_index_dir), + "device": self.device_name, + "manifest": manifest, + "offset": offset, + "batch_size": batch_size, + "files_remaining": files_remaining, + "resumed": resume, + } + + def _list_assets(self) -> Dict[str, List[Path]]: + assets: Dict[str, List[Path]] = {role: [] for role in self.ROLE_PATTERNS} + if not self.library_dir.exists(): + return assets + + for role, patterns in self.ROLE_PATTERNS.items(): + seen = set() + indexed_paths = set(self._sample_index_by_path.keys()) | set(self._vector_store_meta_by_path.keys()) + + for normalized_path in sorted(indexed_paths): + path = Path(normalized_path) + if not path.exists() or not path.is_file(): + continue + if path.suffix.lower() not in {'.wav', '.aif', '.aiff', '.mp3'}: + continue + sample_meta = self._sample_index_by_path.get(normalized_path) + vector_meta = self._vector_store_meta_by_path.get(normalized_path) + if not self._catalog_role_match(role, path, sample_meta, vector_meta): + continue + if normalized_path in seen: + continue + seen.add(normalized_path) + assets[role].append(path) + + for pattern in patterns: + for match in sorted(self.library_dir.glob(pattern)): + if match.is_file() and match.suffix.lower() in {'.wav', '.aif', '.aiff', '.mp3'}: + normalized_match = str(match.resolve()).lower() + sample_meta = self._sample_index_by_path.get(normalized_match) + vector_meta = self._vector_store_meta_by_path.get(normalized_match) + if not self._catalog_role_match(role, match, sample_meta, vector_meta): + continue + if normalized_match in seen: + continue + seen.add(normalized_match) + assets[role].append(match) + return assets + + def _apply_role_exclusions(self, role: str, file_name: str) -> Tuple[bool, str]: + """ + Aplica exclusiones fuertes por rol. + + Retorna: + (should_reject, reason) - True si debe rechazar, False si pasa + """ + role_lower = role.lower() + if role_lower not in self.ROLE_EXCLUSION_PATTERNS: + return False, "" + + name_lower = file_name.lower() + exclusions = self.ROLE_EXCLUSION_PATTERNS[role_lower] + + for excl in exclusions: + if excl in name_lower: + return True, f"excluded pattern '{excl}' for role '{role}'" + + return False, "" + + def _validate_role_requirement(self, role: str, item: Dict[str, Any]) -> Tuple[bool, float, str]: + """ + Validates that a candidate sample meets role requirements. + + Returns: + (passes, score_modifier, reason) - True if passes, score modifier (0-1), reason string + """ + role_lower = role.lower() + file_name = str(item.get("file_name", "") or "").lower() + duration = float(item.get("duration", 0.0) or 0.0) + + min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role_lower, (0.0, 999.0)) + + if duration > 0.0 and not (min_dur <= duration <= max_dur): + return False, 0.0, f"duration {duration:.1f}s outside range [{min_dur}, {max_dur}] for role {role}" + + if role_lower in {'kick', 'snare', 'hat', 'clap', 'hat_closed', 'hat_open'}: + if 'loop' in file_name and 'full' not in file_name: + if duration > 4.0: + return False, 0.3, f"one-shot role {role} has loop-like file (duration={duration:.1f}s)" + + if role_lower in {'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop'}: + if duration < 1.0: + return False, 0.2, f"loop role {role} has very short duration ({duration:.1f}s)" + + must_contain = { + 'kick': ['kick', 'bd', 'bass_drum', '808'], + 'snare': ['snare', 'snr', 'sd', 'rim'], + 'clap': ['clap', 'clp', 'hand'], + 'hat': ['hat', 'hh', 'hihat', 'cymbal'], + 'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline'], + 'vocal_loop': ['vocal', 'vox', 'voice', 'chant', 'acapella'], + 'top_loop': ['top', 'perc', 'drum', 'full'], + 'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck'], + 'crash_fx': ['crash', 'cymbal', 'impact', 'ride'], + 'fill_fx': ['fill', 'transition', 'tom'], + 'snare_roll': ['roll', 'snare', 'build'], + 'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise'], + 'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab'], + } + + if role_lower in must_contain: + found = any(kw in file_name for kw in must_contain[role_lower]) + if not found: + return True, 0.65, f"no role keyword for {role}" + + return True, 1.0, "passes role validation" + + def _matches_role_name(self, role: str, file_name: str) -> bool: + name = Path(file_name).stem.lower() + # Check exclusions first + should_reject, reason = self._apply_role_exclusions(role, name) + if should_reject: + logger.debug("ROLE_EXCLUSION: %s", reason) + return False + if role == 'kick': + return 'kick' in name and 'loop' not in name + if role == 'snare': + return ('snare' in name or 'clap' in name) and 'roll' not in name and 'loop' not in name + if role == 'hat': + return 'hat' in name and 'loop' not in name and 'full mix' not in name + if role == 'bass_loop': + return self._name_contains_any(name, ('bass loop', 'bass_loop', 'bassline', 'sub bass', 'sub_bass', 'reese', '808')) \ + and self._name_contains_none(name, ('drum loop', 'full mix', 'top loop', 'vocal')) + if role == 'perc_loop': + return ( + self._name_contains_any(name, ('perc loop', 'perc_loop', 'percussion loop', 'drum loop', 'drum_loop', 'groove')) + or ('perc' in name and 'loop' in name) + or (self._name_contains_any(name, ('shaker', 'bongo', 'conga', 'timbale')) and 'loop' in name) + ) and self._name_contains_none(name, ('full mix', 'one shot', 'shot', 'vocal')) + if role == 'top_loop': + return self._name_contains_any(name, ('top loop', 'top_loop', 'top loops', 'full drum', 'full mix', 'drum loop', 'drum_loop')) \ + and self._name_contains_none(name, ('bass loop', 'vocal', 'synth loop')) + if role == 'synth_loop': + return ( + self._name_contains_any(name, ('synth loop', 'synth_loop', 'lead loop', 'lead_loop', 'hook', 'melody loop', 'melodic loop')) + or ('synth' in name and 'loop' in name) + or (self._name_contains_any(name, ('chord', 'pad', 'pluck', 'arp')) and 'loop' in name) + ) and self._name_contains_none(name, ('drum loop', 'full mix', 'vocal')) + if role == 'vocal_loop': + return ( + self._name_contains_any(name, ('vocal loop', 'vox loop', 'vox_', 'acapella', 'chant loop')) + or ('vocal' in name and 'loop' in name) + ) and self._name_contains_none(name, ('one shot', 'shot', 'importante', 'stab', 'hit')) + if role == 'crash_fx': + return self._name_contains_any(name, ('crash', 'cymbal', 'riser', 'downlifter', 'sweep', 'uplifter')) or ' impact ' in f" {name} " + if role == 'fill_fx': + return self._name_contains_any(name, ('fill', 'transition', 'tom loop', 'drum fill', 'break fill')) + if role == 'snare_roll': + return self._name_contains_any(name, ('snareroll', 'snare roll', 'roll', 'buildup')) and 'one shot' not in name + if role == 'atmos_fx': + return self._name_contains_any(name, ('atmos', 'drone', 'ambient', 'noise', 'texture', 'downfilter', 'wash', 'sweep')) + if role == 'vocal_shot': + return self._name_contains_any(name, ('vocal one shot', 'one shot', 'shot', 'importante', 'vocal chop', 'vocal stab')) + return True + + def _cosine_scores(self, reference_vector: List[float], candidate_vectors: List[List[float]]) -> List[float]: + if not candidate_vectors: + return [] + + ref = np.asarray(reference_vector, dtype=np.float32) + candidates = np.asarray(candidate_vectors, dtype=np.float32) + if torch is None or self.device is None or F is None: + ref_norm = np.linalg.norm(ref) or 1.0 + cand_norm = np.linalg.norm(candidates, axis=1) + cand_norm[cand_norm == 0] = 1.0 + return (candidates @ ref / (cand_norm * ref_norm)).astype(float).tolist() + + ref_tensor = torch.tensor(ref, dtype=torch.float32, device=self.device) + candidate_tensor = torch.tensor(candidates, dtype=torch.float32, device=self.device) + scores = F.cosine_similarity(candidate_tensor, ref_tensor.unsqueeze(0), dim=1) + return scores.detach().cpu().numpy().astype(float).tolist() + + def _cosine_matrix(self, left_vectors: List[List[float]], right_vectors: List[List[float]]) -> np.ndarray: + if not left_vectors or not right_vectors: + return np.zeros((0, 0), dtype=np.float32) + + left = np.asarray(left_vectors, dtype=np.float32) + right = np.asarray(right_vectors, dtype=np.float32) + + if torch is None or self.device is None or F is None: + left_norm = np.linalg.norm(left, axis=1, keepdims=True) + right_norm = np.linalg.norm(right, axis=1, keepdims=True) + left_norm[left_norm == 0] = 1.0 + right_norm[right_norm == 0] = 1.0 + return (left / left_norm) @ (right / right_norm).T + + left_tensor = torch.tensor(left, dtype=torch.float32, device=self.device) + right_tensor = torch.tensor(right, dtype=torch.float32, device=self.device) + left_tensor = F.normalize(left_tensor, p=2, dim=1) + right_tensor = F.normalize(right_tensor, p=2, dim=1) + return (left_tensor @ right_tensor.T).detach().cpu().numpy().astype(np.float32) + + def _tempo_score(self, candidate_tempo: float, reference_tempo: float) -> float: + if candidate_tempo <= 0 or reference_tempo <= 0: + return 0.5 + variants = [ + candidate_tempo, + candidate_tempo * 2.0, + candidate_tempo / 2.0, + candidate_tempo * 4.0, + candidate_tempo / 4.0, + ] + diff = min(abs(item - reference_tempo) for item in variants) + return math.exp(-diff / 10.0) + + def _vector_store_entry(self, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]: + path_key = str(candidate.get("path", "") or "").strip().lower() + if path_key and path_key in self._vector_store_meta_by_path: + return self._vector_store_meta_by_path[path_key] + file_name = str(candidate.get("file_name", "") or Path(path_key).name).strip().lower() + if file_name and file_name in self._vector_store_meta_by_name: + return self._vector_store_meta_by_name[file_name] + return None + + def _role_segment_relevance(self, role: str, segment: Dict[str, Any], reference: Dict[str, Any]) -> float: + kind = str(segment.get("kind", "verse") or "verse").lower() + centroid = float(segment.get("spectral_centroid", 0.0) or 0.0) + onset = float(segment.get("onset_mean", 0.0) or 0.0) + harmonic = float(segment.get("harmonic_ratio", 0.5) or 0.5) + percussive = float(segment.get("percussive_ratio", 0.5) or 0.5) + flatness = float(segment.get("spectral_flatness", 0.0) or 0.0) + zcr = float(segment.get("zero_crossing_rate", 0.0) or 0.0) + rms = float(segment.get("rms_mean", 0.5) or 0.5) + score = 0.0 + + if role == 'kick': + transient = min(1.0, onset / 3.0) + low_centroid = max(0.0, 1.0 - (centroid / 3000.0)) + score = transient * 0.35 + percussive * 0.30 + low_centroid * 0.20 + rms * 0.15 + elif role == 'snare': + transient = min(1.0, onset / 4.5) + mid_centroid = min(1.0, max(0.0, (centroid - 800) / 4000.0)) + score = transient * 0.32 + percussive * 0.28 + mid_centroid * 0.25 + elif role == 'hat': + high_centroid = min(1.0, centroid / 10000.0) + transient = min(1.0, onset / 4.0) + score = high_centroid * 0.38 + transient * 0.32 + zcr * 0.15 + percussive * 0.15 + elif role == 'bass_loop': + low_centroid = max(0.0, 1.0 - (centroid / 2200.0)) + harmonic_content = harmonic * 0.35 + low_flat = max(0.0, 1.0 - flatness * 1.5) + score = harmonic_content + low_centroid * 0.30 + low_flat * 0.20 + rms * 0.15 + elif role in {'perc_loop', 'top_loop'}: + transient = min(1.0, onset / 4.0) + mid_high_centroid = min(1.0, max(0.0, centroid / 8500.0)) + score = transient * 0.35 + percussive * 0.30 + mid_high_centroid * 0.20 + rms * 0.15 + elif role == 'synth_loop': + harmonic_content = harmonic * 0.38 + mid_centroid = min(1.0, max(0.0, (centroid - 500) / 7000.0)) + low_flat = max(0.0, 1.0 - flatness * 1.2) + score = harmonic_content + mid_centroid * 0.22 + low_flat * 0.25 + elif role == 'vocal_loop': + harmonic_content = harmonic * 0.32 + mid_centroid = min(1.0, max(0.0, (centroid - 200) / 4000.0)) + low_flat = max(0.0, 1.0 - flatness * 1.5) + score = harmonic_content + mid_centroid * 0.18 + low_flat * 0.25 + rms * 0.25 + elif role == 'crash_fx': + high_centroid = min(1.0, centroid / 12000.0) + transient = min(1.0, onset / 3.5) + high_flat = min(1.0, flatness * 2.5) + score = high_centroid * 0.30 + transient * 0.25 + high_flat * 0.25 + elif role == 'fill_fx': + transient = min(1.0, onset / 4.0) + percussive_content = percussive * 0.35 + mid_centroid = min(1.0, max(0.0, centroid / 7000.0)) + score = transient * 0.30 + percussive_content + mid_centroid * 0.20 + elif role == 'snare_roll': + transient = min(1.0, onset / 4.5) + percussive_content = percussive * 0.38 + mid_centroid = min(1.0, max(0.0, (centroid - 1000) / 5000.0)) + score = transient * 0.35 + percussive_content + mid_centroid * 0.15 + elif role == 'atmos_fx': + harmonic_content = harmonic * 0.28 + low_onset = max(0.0, 1.0 - onset * 2.0) + high_flat = min(1.0, flatness * 2.0) + score = harmonic_content + low_onset * 0.22 + high_flat * 0.25 + rms * 0.25 + elif role == 'vocal_shot': + harmonic_content = harmonic * 0.30 + transient = min(1.0, onset / 4.0) + mid_centroid = min(1.0, max(0.0, (centroid - 300) / 4500.0)) + score = harmonic_content + transient * 0.22 + mid_centroid * 0.28 + + section_bonus_map = { + 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': 0.02}, + 'snare': {'intro': -0.06, 'verse': 0.06, 'build': 0.10, 'drop': 0.14, 'break': 0.03, 'outro': -0.04}, + 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.14, 'drop': 0.12, 'break': -0.04, 'outro': 0.02}, + 'bass_loop': {'intro': -0.12, 'verse': 0.06, 'build': 0.12, 'drop': 0.20, 'break': -0.10, 'outro': -0.06}, + 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.18, 'break': 0.06, 'outro': 0.00}, + 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.16, 'drop': 0.18, 'break': 0.02, 'outro': 0.00}, + 'synth_loop': {'intro': 0.06, 'verse': 0.04, 'build': 0.14, 'drop': 0.20, 'break': 0.12, 'outro': 0.02}, + 'vocal_loop': {'intro': -0.06, 'verse': 0.14, 'build': 0.08, 'drop': 0.16, 'break': 0.10, 'outro': -0.02}, + 'crash_fx': {'intro': 0.10, 'verse': 0.02, 'build': 0.16, 'drop': 0.10, 'break': -0.06, 'outro': 0.10}, + 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.20, 'drop': 0.12, 'break': 0.10, 'outro': 0.02}, + 'snare_roll': {'intro': -0.08, 'verse': 0.02, 'build': 0.26, 'drop': 0.14, 'break': 0.06, 'outro': -0.10}, + 'atmos_fx': {'intro': 0.22, 'verse': 0.04, 'build': 0.02, 'drop': -0.06, 'break': 0.24, 'outro': 0.18}, + 'vocal_shot': {'intro': -0.06, 'verse': 0.10, 'build': 0.12, 'drop': 0.16, 'break': 0.08, 'outro': -0.04}, + } + score += section_bonus_map.get(role, {}).get(kind, 0.0) + return max(0.0, min(1.0, score)) + + def _select_role_reference_segments( + self, + role: str, + reference: Dict[str, Any], + segment_bank: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + if not segment_bank: + return [] + settings = ROLE_SEGMENT_SETTINGS.get(role, {}) + allowed_windows = settings.get("windows", set()) + allowed_kinds = settings.get("section_kinds", set()) + filtered = [ + segment for segment in segment_bank + if (not allowed_windows or round(float(segment.get("window_seconds", 0.0)), 1) in allowed_windows) + and (not allowed_kinds or str(segment.get("kind", "")).lower() in allowed_kinds) + ] + if not filtered: + filtered = segment_bank + ranked = sorted( + filtered, + key=lambda item: self._role_segment_relevance(role, item, reference), + reverse=True, + ) + return ranked[:int(settings.get("top_k", 6) or 6)] + + def _role_segment_similarity( + self, + role: str, + candidate: Dict[str, Any], + role_segments: List[Dict[str, Any]], + ) -> float: + role_vectors = [list(segment.get("vector", []) or []) for segment in role_segments if segment.get("vector")] + if not role_vectors: + return 0.0 + + candidate_vectors: List[List[float]] = [] + candidate_vector = list(candidate.get("deep_vector", []) or []) + if candidate_vector: + candidate_vectors.append(candidate_vector) + + candidate_path = str(candidate.get("path", "") or "") + candidate_duration = float(candidate.get("duration", 0.0) or 0.0) + windows = set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) + if candidate_path and candidate_duration > max(windows or {4.0}) * 1.5: + segment_bank = self._build_candidate_segment_bank(candidate_path, windows, duration_limit=min(max(candidate_duration, 0.0), 32.0)) + candidate_vectors.extend( + list(segment.get("vector", []) or []) + for segment in segment_bank + if segment.get("vector") + ) + + if not candidate_vectors: + return 0.0 + + matrix = self._cosine_matrix(candidate_vectors, role_vectors) + if matrix.size == 0: + return 0.0 + best_per_candidate = matrix.max(axis=1).tolist() + best_per_candidate.sort(reverse=True) + top = best_per_candidate[: min(3, len(best_per_candidate))] + return float(sum(top) / len(top)) + + def _vector_store_role_score(self, role: str, candidate: Dict[str, Any], reference: Dict[str, Any]) -> float: + entry = self._vector_store_entry(candidate) + if not entry: + return 0.5 + + entry_type = str(entry.get("type", "") or "").lower() + duration = float(entry.get("duration_estimate", candidate.get("duration", 0.0)) or 0.0) + tags = [str(tag).lower() for tag in entry.get("tags", []) if tag] + file_name = str(candidate.get("file_name", entry.get("filename", "")) or "").lower() + + type_score = 0.6 if not entry_type else (1.0 if entry_type in ROLE_VECTOR_TYPES.get(role, set()) else 0.35) + duration_score = self._duration_score(role, duration, file_name) + tag_score = self._naming_score(role, " ".join(tags + [file_name])) + tempo_score = self._tempo_score(float(entry.get("bpm", candidate.get("tempo", 0.0)) or 0.0), float(reference.get("tempo", 0.0) or 0.0)) + score = type_score * 0.34 + duration_score * 0.28 + tag_score * 0.26 + tempo_score * 0.12 + if role == 'crash_fx' and any(marker in file_name for marker in ['top loop', 'top loops', 'hat', 'snare']): + score *= 0.25 + return max(0.0, min(1.0, score)) + + def _role_score( + self, + role: str, + reference: Dict[str, Any], + candidate: Dict[str, Any], + cosine_score: float, + segment_score: float = 0.0, + catalog_score: float = 0.5, + ) -> float: + if segment_score > 0: + if role in {'kick', 'snare', 'hat', 'crash_fx', 'fill_fx', 'snare_roll', 'vocal_shot'}: + cosine_score = (float(cosine_score) * 0.28) + (float(segment_score) * 0.72) + elif role in {'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx'}: + cosine_score = (float(cosine_score) * 0.42) + (float(segment_score) * 0.58) + else: + cosine_score = (float(cosine_score) * 0.5) + (float(segment_score) * 0.5) + tempo_score = self._tempo_score(float(candidate.get("tempo", 0.0)), float(reference.get("tempo", 0.0))) + key_distance = _key_distance(reference.get("key"), candidate.get("key")) + key_score = max(0.0, 1.0 - (key_distance / 6.0)) + duration = float(candidate.get("duration", 0.0)) + onset = float(candidate.get("onset_mean", 0.0)) + rms = float(candidate.get("rms_mean", 0.0)) + file_name = str(candidate.get("file_name", "") or "").lower() + duration_score = self._duration_score(role, duration, file_name) + naming_score = self._naming_score(role, file_name) + spectral_score = self._spectral_role_score(role, candidate) + + if role in ['kick', 'snare', 'hat']: + base_score = ( + cosine_score * 0.18 + + tempo_score * 0.10 + + min(1.0, onset / 4.0) * 0.20 + + duration_score * 0.22 + + naming_score * 0.18 + + spectral_score * 0.12 + ) + elif role == 'bass_loop': + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.20 + + key_score * 0.20 + + duration_score * 0.16 + + min(1.0, rms / 0.5) * 0.08 + + spectral_score * 0.12 + ) + elif role in ['perc_loop', 'top_loop']: + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.26 + + key_score * 0.06 + + duration_score * 0.16 + + min(1.0, onset / 3.5) * 0.16 + + spectral_score * 0.12 + ) + elif role == 'synth_loop': + base_score = ( + cosine_score * 0.24 + + tempo_score * 0.16 + + key_score * 0.22 + + duration_score * 0.16 + + naming_score * 0.10 + + spectral_score * 0.12 + ) + elif role == 'vocal_loop': + base_score = ( + cosine_score * 0.26 + + tempo_score * 0.20 + + key_score * 0.06 + + duration_score * 0.18 + + naming_score * 0.18 + + spectral_score * 0.12 + ) + elif role == 'crash_fx': + base_score = ( + cosine_score * 0.14 + + tempo_score * 0.06 + + duration_score * 0.28 + + naming_score * 0.32 + + min(1.0, onset / 3.0) * 0.08 + + spectral_score * 0.12 + ) + elif role == 'fill_fx': + base_score = ( + cosine_score * 0.16 + + tempo_score * 0.16 + + duration_score * 0.22 + + naming_score * 0.22 + + min(1.0, onset / 3.0) * 0.12 + + spectral_score * 0.12 + ) + elif role == 'snare_roll': + base_score = ( + cosine_score * 0.14 + + tempo_score * 0.12 + + duration_score * 0.20 + + naming_score * 0.28 + + min(1.0, onset / 2.5) * 0.14 + + spectral_score * 0.12 + ) + elif role == 'atmos_fx': + base_score = ( + cosine_score * 0.28 + + tempo_score * 0.06 + + key_score * 0.16 + + duration_score * 0.22 + + naming_score * 0.16 + + spectral_score * 0.12 + ) + elif role == 'vocal_shot': + base_score = ( + cosine_score * 0.20 + + tempo_score * 0.10 + + key_score * 0.12 + + duration_score * 0.20 + + naming_score * 0.26 + + spectral_score * 0.12 + ) + else: + base_score = cosine_score * 0.5 + tempo_score * 0.3 + key_score * 0.2 + + return float(base_score) * (0.82 + (0.24 * float(catalog_score))) + + def _spectral_role_score(self, role: str, candidate: Dict[str, Any]) -> float: + """Score candidate based on spectral characteristics for the role.""" + centroid = float(candidate.get("spectral_centroid", 0.0)) + rolloff = float(candidate.get("spectral_rolloff", 0.0)) + rms_std = float(candidate.get("rms_std", 0.0)) + onset_mean = float(candidate.get("onset_mean", 0.0)) + rms_mean = float(candidate.get("rms_mean", 0.0)) + + # Compute spectral spread indicator + rms_spread = min(1.0, rms_std / max(0.01, rms_mean)) if rms_mean > 0 else 0.5 + + # Transient score based on onset + transient_score = min(1.0, onset_mean / 3.0) + + # Get expected signature for role + sig = SPECTRAL_ROLE_SIGNATURES.get(role) + if not sig: + return 0.5 + + score = 0.0 + + # Centroid match + centroid_min, centroid_max = sig.get('centroid_range', (0, 20000)) + if centroid_min <= centroid <= centroid_max: + score += 0.25 + else: + # Partial score for being close + dist = min(abs(centroid - centroid_min), abs(centroid - centroid_max)) + score += 0.25 * math.exp(-dist / 2000) + + # Rolloff match + rolloff_min, rolloff_max = sig.get('rolloff_range', (0, 20000)) + if rolloff_min <= rolloff <= rolloff_max: + score += 0.25 + else: + dist = min(abs(rolloff - rolloff_min), abs(rolloff - rolloff_max)) + score += 0.25 * math.exp(-dist / 3000) + + # RMS spread match (for one-shots vs loops) + spread_min, spread_max = sig.get('rms_spread', (0.0, 1.0)) + if spread_min <= rms_spread <= spread_max: + score += 0.25 + else: + dist = min(abs(rms_spread - spread_min), abs(rms_spread - spread_max)) + score += 0.25 * math.exp(-dist / 0.3) + + # Transient score match + trans_min, trans_max = sig.get('transient_score', (0.0, 1.0)) + if trans_min <= transient_score <= trans_max: + score += 0.25 + else: + dist = min(abs(transient_score - trans_min), abs(transient_score - trans_max)) + score += 0.25 * math.exp(-dist / 0.3) + + return min(1.0, max(0.0, score)) + + def _duration_score(self, role: str, duration: float, file_name: str) -> float: + """Improved duration scoring with better one-shot vs loop detection.""" + file_lower = file_name.lower() + + # One-shot roles: kick, snare/clap, hat + if role in ['kick', 'snare', 'hat']: + # Ideal one-shot duration: 0.1 - 1.5 seconds + is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower + is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower + + if is_explicit_shot and duration < 3.0: + return 1.0 + if is_explicit_loop: + return 0.35 + + # Duration-based scoring for one-shots + if duration < 0.1: + return 0.4 # Too short, probably artifact + if duration < 2.0: + # Sweet spot for one-shots + peak = 0.5 if role == 'kick' else (0.8 if role == 'hat' else 0.6) + score = math.exp(-abs(duration - peak) / 1.0) + return max(0.0, min(1.0, score)) + if duration < 4.0: + # Could be a roll or extended hit + return 0.5 if 'roll' in file_lower else 0.3 + return 0.2 # Too long for one-shot + + # Loop roles: bass, perc, top, synth, vocal + if role in ['bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop']: + is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower + is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower + + if is_explicit_shot: + return 0.25 # One-shot marked as loop role + + # Ideal loop duration: 2 - 16 seconds (typically 4 or 8 bars) + if duration < 0.5: + return 0.2 # Too short for a proper loop + if duration < 2.0: + # Short loop, acceptable but not ideal + base_score = duration / 2.0 + if is_explicit_loop: + base_score += 0.2 + return min(1.0, base_score) + if duration < 12.0: + # Sweet spot for loops (2-8 bars typically) + score = min(1.0, duration / 6.0) + if is_explicit_loop: + score = min(1.0, score + 0.15) + return score + if duration < 20.0: + # Longer loop, still acceptable + return 0.75 if is_explicit_loop else 0.6 + return 0.5 # Very long loop + + # FX roles + if role == 'crash_fx': + # Crashes: 0.5 - 4 seconds + if any(marker in file_lower for marker in ['loop', 'top', 'hat', 'snare']): + return 0.15 + if duration < 0.3: + return 0.3 + if duration < 5.0: + return math.exp(-abs(duration - 2.0) / 2.5) + return 0.4 + + if role in ['fill_fx', 'snare_roll']: + # Fills/rolls: 1 - 8 seconds + if duration < 0.5: + return 0.3 + if duration < 8.0: + return math.exp(-abs(duration - 4.0) / 3.0) + return 0.5 + + if role == 'atmos_fx': + # Atmos: longer, sustained sounds + if duration < 2.0: + return 0.4 + if duration < 30.0: + return min(1.0, duration / 12.0) + return 0.8 + + if role == 'vocal_shot': + # Vocal shots: short one-shots + if duration < 0.2: + return 0.5 + if duration < 2.0: + return math.exp(-abs(duration - 0.8) / 1.2) + if duration < 4.0: + return 0.4 + return 0.25 + + return 0.5 + + def _naming_score(self, role: str, file_name: str) -> float: + if role == 'kick': + if 'loop' in file_name: + return 0.45 + return 1.0 if 'kick' in file_name else 0.7 + if role == 'snare': + if 'roll' in file_name: + return 0.4 + if 'clap' in file_name or 'snare' in file_name: + return 1.0 + return 0.7 + if role == 'hat': + if 'loop' in file_name: + return 0.7 + if 'closed' in file_name or 'hat' in file_name: + return 1.0 + return 0.75 + if role == 'vocal_loop': + if 'vocal' in file_name or 'vox' in file_name: + return 1.0 + return 0.7 + if role == 'top_loop': + if 'top' in file_name or 'full drum' in file_name: + return 1.0 + if 'perc' in file_name: + return 0.58 + return 0.85 if 'loop' in file_name else 0.65 + if role in ['bass_loop', 'perc_loop', 'synth_loop']: + return 1.0 if 'loop' in file_name else 0.72 + if role == 'crash_fx': + if 'crash' in file_name: + return 1.0 + if 'impact' in file_name: + return 0.9 + if any(marker in file_name for marker in ['top loop', 'top loops', 'closed hat', 'open hat', 'snare', 'roll']): + return 0.2 + return 0.65 + if role == 'fill_fx': + if 'fill' in file_name: + return 1.0 + if 'tom' in file_name or 'roll' in file_name: + return 0.84 + return 0.62 + if role == 'snare_roll': + if 'roll' in file_name: + return 1.0 + if 'snare' in file_name or 'fill' in file_name: + return 0.82 + return 0.55 + if role == 'atmos_fx': + if 'atmos' in file_name: + return 1.0 + if 'drone' in file_name or 'noise' in file_name: + return 0.82 + return 0.64 + if role == 'vocal_shot': + if 'vocal' in file_name or 'importante' in file_name: + return 1.0 + if 'shot' in file_name: + return 0.88 + return 0.64 + return 0.8 + + def _candidate_path(self, item: Optional[Dict[str, Any]]) -> str: + if not isinstance(item, dict): + return "" + return str(item.get("path", "") or "").strip().lower() + + def _candidate_family(self, item: Optional[Dict[str, Any]]) -> str: + if not isinstance(item, dict): + return "" + + file_name = str(item.get("file_name", "") or Path(str(item.get("path", "") or "")).name).strip().lower() + stem = Path(file_name).stem.lower() + if not stem: + return "" + + markers = [ + " - kick", " - snare", " - clap", " - closed hat", " - open hat", " - hat", + " - bass loop", " - percussion loop", " - percussion", " - perc loop", + " - top loop", " - synth loop", " - vocal loop", " - vocal one shot", + " - fill", " - snareroll", " - snare roll", " - crash", " - atmos", + ] + for marker in markers: + if marker in stem: + return stem.split(marker, 1)[0].strip() + + if " - " in stem: + return " - ".join(part.strip() for part in stem.split(" - ")[:2] if part.strip()) + if "_" in stem: + return "_".join(stem.split("_")[:2]).strip("_") + + words = stem.split() + return " ".join(words[:2]) if words else stem + + def _remember_candidate(self, item: Optional[Dict[str, Any]]) -> None: + path_key = self._candidate_path(item) + family_key = self._candidate_family(item) + if path_key: + self._recent_paths.append(path_key) + if hasattr(self, '_generation_path_usage'): + self._generation_path_usage[path_key] += 1 + if family_key: + self._recent_families.append(family_key) + # Track usage count for progressive penalty + self._family_usage_count[family_key] = self._family_usage_count.get(family_key, 0) + 1 + if hasattr(self, '_generation_family_usage'): + self._generation_family_usage[family_key] += 1 + + def _get_family_penalty(self, family_key: str) -> float: + """Calculate progressive penalty for repeated families.""" + if not family_key: + return 1.0 + + if family_key in self._recent_families: + return 0.08 + + usage_count = self._family_usage_count.get(family_key, 0) + if usage_count == 0: + return 1.0 + if usage_count == 1: + return 0.45 + if usage_count == 2: + return 0.22 + if usage_count >= 3: + return 0.08 + + return 1.0 + + def _get_cross_generation_family_penalty(self, family_key: str) -> float: + """Penaliza familias usadas en generaciones previas de referencia.""" + if not family_key: + return 1.0 + usage_count = int(_cross_generation_reference_family_memory.get(family_key, 0) or 0) + if usage_count <= 0: + return 1.0 + if usage_count == 1: + return 0.55 + if usage_count == 2: + return 0.30 + if usage_count >= 3: + return 0.08 + return max(0.08, 1.0 - (usage_count * 0.18)) + + def _get_cross_generation_path_penalty(self, path_key: str) -> float: + """Penaliza paths usados en generaciones previas de referencia.""" + if not path_key: + return 1.0 + usage_count = int(_cross_generation_reference_path_memory.get(path_key, 0) or 0) + if usage_count <= 0: + return 1.0 + if usage_count == 1: + return 0.40 + if usage_count >= 2: + return 0.15 + return max(0.25, 1.0 - (usage_count * 0.20)) + + def _select_candidate(self, role: str, items: List[Dict[str, Any]], rng: random.Random, + section_kind: str = "", section_energy: float = 0.5) -> Optional[Dict[str, Any]]: + if not items: + return None + + pool_sizes = { + "kick": 16, + "snare": 16, + "hat": 18, + "bass_loop": 14, + "perc_loop": 16, + "top_loop": 14, + "synth_loop": 14, + "vocal_loop": 12, + "crash_fx": 10, + "fill_fx": 12, + "snare_roll": 10, + "atmos_fx": 10, + "vocal_shot": 12, + } + pool_size = min(pool_sizes.get(role, 10), len(items)) + candidates = list(items[:pool_size]) + + section_bonus = { + 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': -0.06, 'outro': 0.02}, + 'snare': {'intro': -0.08, 'verse': 0.06, 'build': 0.10, 'drop': 0.12, 'break': 0.04, 'outro': -0.06}, + 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.12, 'drop': 0.10, 'break': -0.04, 'outro': 0.02}, + 'bass_loop': {'intro': -0.10, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': -0.04}, + 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.04, 'outro': 0.00}, + 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.02, 'outro': 0.00}, + 'synth_loop': {'intro': 0.04, 'verse': 0.06, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.02}, + 'vocal_loop': {'intro': -0.04, 'verse': 0.12, 'build': 0.08, 'drop': 0.14, 'break': 0.08, 'outro': -0.02}, + 'crash_fx': {'intro': 0.08, 'verse': 0.02, 'build': 0.14, 'drop': 0.08, 'break': -0.04, 'outro': 0.08}, + 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.16, 'drop': 0.10, 'break': 0.08, 'outro': 0.02}, + 'snare_roll': {'intro': -0.06, 'verse': 0.02, 'build': 0.22, 'drop': 0.12, 'break': 0.04, 'outro': -0.08}, + 'atmos_fx': {'intro': 0.20, 'verse': 0.04, 'build': 0.02, 'drop': -0.04, 'break': 0.20, 'outro': 0.16}, + 'vocal_shot': {'intro': -0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': 0.06, 'outro': -0.02}, + } + + weighted: List[Tuple[float, Dict[str, Any]]] = [] + + for index, item in enumerate(candidates): + score = max(0.001, float(item.get("score", 0.001))) + rank_penalty = max(0.30, 1.0 - (index * 0.055)) + + passes_validation, validation_mod, validation_reason = self._validate_role_requirement(role, item) + if not passes_validation: + continue + + score *= validation_mod + + path_key = self._candidate_path(item) + path_penalty = 0.12 if path_key in self._recent_paths else 1.0 + + family_key = self._candidate_family(item) + family_penalty = self._get_family_penalty(family_key) + cross_family_penalty = self._get_cross_generation_family_penalty(family_key) + cross_path_penalty = self._get_cross_generation_path_penalty(path_key) + + section_bonus_val = section_bonus.get(role.lower(), {}).get(section_kind.lower(), 0.0) + if section_kind.lower() in {'drop', 'build'} and section_energy > 0.7: + section_bonus_val *= 1.2 + elif section_kind.lower() in {'break', 'intro'} and section_energy < 0.4: + section_bonus_val *= 1.2 + + energy_mod = 1.0 + rms = float(item.get("rms_mean", 0.0) or 0.0) + if role.lower() in {"kick", "snare", "bass_loop"}: + if rms > 0.08: + energy_mod = min(1.15, 1.0 + (rms - 0.08) * 2.0) + elif rms < 0.03 and section_kind.lower() not in {"intro", "break"}: + energy_mod = 0.85 + + role_randomness = 0.88 + (rng.random() * 0.24) + + weight = ( + (score ** 1.7) + * rank_penalty + * path_penalty + * family_penalty + * cross_family_penalty + * cross_path_penalty + * role_randomness + * energy_mod + ) + + if section_bonus_val > 0: + weight *= (1.0 + section_bonus_val) + elif section_bonus_val < 0: + weight *= (1.0 + section_bonus_val * 0.5) + + weighted.append((max(0.001, weight), item)) + + if not weighted: + weighted = [(max(0.001, float(item.get("score", 0.001))), item) for item in candidates] + + total = sum(weight for weight, _ in weighted) + if total <= 0: + return candidates[0] if candidates else None + + pivot = rng.random() * total + running = 0.0 + for weight, item in weighted: + running += weight + if pivot <= running: + return item + + return weighted[0][1] + + def _select_distinct_candidate( + self, + role: str, + items: List[Dict[str, Any]], + rng: random.Random, + used_paths: set, + used_families: set, + section_kind: str = "", + section_energy: float = 0.5, + ) -> Optional[Dict[str, Any]]: + if not items: + return None + + filtered = [ + item for item in items + if self._candidate_path(item) not in used_paths + ] + + family_filtered = [ + item for item in filtered + if self._candidate_family(item) not in used_families + ] + + pool = family_filtered if family_filtered else filtered if filtered else items + + selected = self._select_candidate(role, pool, rng, section_kind, section_energy) + selected_path = self._candidate_path(selected) + selected_family = self._candidate_family(selected) + + if selected_path: + used_paths.add(selected_path) + if selected_family: + used_families.add(selected_family) + + self._remember_candidate(selected) + return selected + + def reset_family_tracking(self) -> None: + """Reset family usage tracking for a new generation.""" + self._family_usage_count.clear() + self._recent_families.clear() + self._recent_paths.clear() + + def start_generation_tracking(self) -> None: + """Inicia tracking de paths/familias para una generación nueva.""" + self._generation_family_usage = defaultdict(int) + self._generation_path_usage = defaultdict(int) + + def end_generation_tracking(self) -> None: + """Actualiza memoria cross-generation de la ruta de referencia.""" + for key in list(_cross_generation_reference_family_memory.keys()): + _cross_generation_reference_family_memory[key] = max(0, _cross_generation_reference_family_memory[key] - 1) + for key in list(_cross_generation_reference_path_memory.keys()): + _cross_generation_reference_path_memory[key] = max(0, _cross_generation_reference_path_memory[key] - 1) + + for family, count in dict(getattr(self, '_generation_family_usage', {})).items(): + if family: + _cross_generation_reference_family_memory[family] += int(count) + for path_key, count in dict(getattr(self, '_generation_path_usage', {})).items(): + if path_key: + _cross_generation_reference_path_memory[path_key] += int(count) + + for key in list(_cross_generation_reference_family_memory.keys()): + if _cross_generation_reference_family_memory[key] <= 0: + del _cross_generation_reference_family_memory[key] + for key in list(_cross_generation_reference_path_memory.keys()): + if _cross_generation_reference_path_memory[key] <= 0: + del _cross_generation_reference_path_memory[key] + + if hasattr(self, '_generation_family_usage'): + delattr(self, '_generation_family_usage') + if hasattr(self, '_generation_path_usage'): + delattr(self, '_generation_path_usage') + + def reset_cross_generation_tracking(self) -> None: + """Resetea la memoria de diversidad entre generaciones para referencia.""" + _cross_generation_reference_family_memory.clear() + _cross_generation_reference_path_memory.clear() + + def reset_recent_sample_diversity_memory(self) -> None: + """Resetea la memoria de diversidad de samples recientes por rol.""" + global _recent_sample_diversity_memory + _recent_sample_diversity_memory.clear() + + def sync_recent_memory_from_selector(self) -> None: + """Sync recent sample diversity memory from sample_selector module.""" + global _recent_sample_diversity_memory + try: + from .sample_selector import _recent_sample_diversity_memory as selector_memory + for role, paths in selector_memory.items(): + if role not in _recent_sample_diversity_memory: + _recent_sample_diversity_memory[role] = [] + for path in paths: + if path not in _recent_sample_diversity_memory[role]: + _recent_sample_diversity_memory[role].append(path) + except ImportError: + pass + + def get_recent_sample_diversity_state(self) -> Dict[str, List[str]]: + """Get copy of recent sample diversity memory.""" + return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()} + + def match_assets(self, reference_path: str) -> Dict[str, Any]: + reference = self.analyze_reference(reference_path) + reference_sections = self.detect_reference_sections(reference_path) + segment_bank = self._build_reference_segment_bank(reference_path, reference, reference_sections) + assets = self._list_assets() + matches: Dict[str, List[Dict[str, Any]]] = {} + role_segments = { + role: self._select_role_reference_segments(role, reference, segment_bank) + for role in assets.keys() + } + rerank_limits = { + "kick": 14, + "snare": 14, + "hat": 16, + "bass_loop": 12, + "perc_loop": 14, + "top_loop": 12, + "synth_loop": 12, + "vocal_loop": 12, + "crash_fx": 10, + "fill_fx": 10, + "snare_roll": 10, + "atmos_fx": 8, + "vocal_shot": 10, + } + + for role, files in assets.items(): + analyses: List[Dict[str, Any]] = [] + vectors: List[List[float]] = [] + for file_path in files: + try: + analysis = self.analyze_file(str(file_path), duration_limit=64.0) + except Exception: + continue + analyses.append(analysis) + vectors.append(list(analysis.get("vector", []))) + + scores = self._cosine_scores(reference.get("vector", []), vectors) + role_matches: List[Dict[str, Any]] = [] + for analysis, cosine_score in zip(analyses, scores): + catalog_score = self._vector_store_role_score(role, analysis, reference) + preliminary_score = self._role_score( + role, + reference, + analysis, + float(cosine_score), + segment_score=0.0, + catalog_score=catalog_score, + ) + role_matches.append({ + "_analysis": analysis, + "_cosine": float(cosine_score), + "_catalog": float(catalog_score), + "_preliminary": float(preliminary_score), + }) + + role_matches.sort(key=lambda item: item["_preliminary"], reverse=True) + rerank_limit = min(int(rerank_limits.get(role, 10) or 10), len(role_matches)) + + role_section_features = self._section_detector._get_role_section_features(role, reference_sections, role_segments.get(role, [])) + + finalized_matches: List[Dict[str, Any]] = [] + for index, item in enumerate(role_matches): + analysis = item["_analysis"] + cosine_score = float(item["_cosine"]) + catalog_score = float(item["_catalog"]) + segment_score = 0.0 + character_bonus = 1.0 + final_score = float(item["_preliminary"]) + + if index < rerank_limit: + segment_score = self._role_segment_similarity(role, analysis, role_segments.get(role, [])) + final_score = self._role_score( + role, + reference, + analysis, + cosine_score, + segment_score=segment_score, + catalog_score=catalog_score, + ) + + if role_section_features: + character_bonus = self._section_detector._section_character_bonus( + role, role_section_features, analysis + ) + final_score = final_score * character_bonus + + finalized_matches.append({ + "path": analysis["path"], + "file_name": analysis["file_name"], + "tempo": analysis["tempo"], + "key": analysis["key"], + "duration": analysis["duration"], + "cosine": round(float(cosine_score), 6), + "segment_score": round(float(segment_score), 6), + "catalog_score": round(float(catalog_score), 6), + "character_bonus": round(float(character_bonus), 3), + "score": round(float(final_score), 6), + }) + + finalized_matches.sort(key=lambda item: item["score"], reverse=True) + matches[role] = finalized_matches + + # Build section energy profile for generator + section_energy_profile = [] + for section in reference_sections: + features = section.get('features', {}) + section_energy_profile.append({ + 'kind': section.get('kind', 'drop'), + 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), + 'energy_peak': features.get('energy_peak', 0.5), + 'energy_slope': features.get('energy_slope', 0.0), + 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), + 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), + 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), + 'low_energy_ratio': features.get('low_energy_ratio', 0.0), + 'high_energy_ratio': features.get('high_energy_ratio', 0.0), + 'kind_confidence': section.get('kind_confidence', 0.5), + }) + + return { + "reference": reference, + "reference_sections": reference_sections, + "segment_bank_size": len(segment_bank), + "role_segments": { + role: [ + { + "start": segment.get("start"), + "end": segment.get("end"), + "kind": segment.get("kind"), + "window_seconds": segment.get("window_seconds"), + } + for segment in items + ] + for role, items in role_segments.items() + }, + "matches": matches, + "section_energy_profile": section_energy_profile, + "device": self.device_name, + } + + def _section_offsets(self, sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: + offsets: List[Tuple[Dict[str, Any], float, float]] = [] + position = 0.0 + for section in sections: + beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) + start = position + end = position + beats + offsets.append((section, start, end)) + position = end + return offsets + + def _section_energy(self, reference: Dict[str, Any], progress: float) -> float: + blocks = reference.get("blocks", []) + if not blocks: + return 0.5 + index = min(len(blocks) - 1, max(0, int(round(progress * (len(blocks) - 1))))) + return float(blocks[index].get("energy_norm", 0.5)) + + def _loop_step_beats(self, item: Optional[Dict[str, Any]], project_bpm: float, default_beats: float = 16.0) -> float: + if not item: + return default_beats + duration = float(item.get("duration", 0.0)) + source_tempo = float(item.get("tempo", 0.0)) + if duration <= 0: + return default_beats + if source_tempo > 0: + source_beats = duration * source_tempo / 60.0 + rounded = max(4.0, round(source_beats / 4.0) * 4.0) + return float(rounded) + estimated = duration * project_bpm / 60.0 + rounded = max(4.0, round(estimated / 4.0) * 4.0) + return float(rounded) + + def _detect_roles_for_segment(self, features: Dict[str, float], section_kind: str) -> List[str]: + """Detect appropriate roles for a segment based on its features and section type.""" + roles = [] + energy = features.get('energy', 0.5) + onset = features.get('onset_density', 0.5) + brightness = features.get('brightness', 0.5) + + # Drums are always present in non-intro/outro sections + if section_kind in ['drop', 'build', 'verse']: + roles.extend(['kick', 'snare', 'hat']) + + # Bass is present in high-energy sections + if section_kind in ['drop', 'build'] or energy > 0.5: + roles.append('bass_loop') + + # Percussion and top loops based on onset density + if onset > 0.4: + roles.extend(['perc_loop', 'top_loop']) + + # Synths in drops and high-brightness sections + if section_kind == 'drop' or (brightness > 0.5 and energy > 0.6): + roles.append('synth_loop') + + # Vocals in drops and verse sections + if section_kind in ['drop', 'verse']: + roles.extend(['vocal_loop', 'vocal_shot']) + + # FX based on section type + if section_kind == 'build': + roles.extend(['snare_roll', 'fill_fx', 'crash_fx']) + elif section_kind == 'break': + roles.extend(['atmos_fx', 'fill_fx']) + elif section_kind == 'intro': + roles.extend(['atmos_fx', 'crash_fx']) + elif section_kind == 'outro': + roles.extend(['atmos_fx', 'crash_fx']) + + return list(set(roles)) + + def _analyze_segment_roles(self, reference: Dict[str, Any], sections: List[Dict[str, Any]]) -> Dict[str, List[str]]: + """Analyze and return recommended roles for each section.""" + segment_roles: Dict[str, List[str]] = {} + + for i, section in enumerate(sections): + kind = str(section.get("kind", "drop")).lower() + + # Use features if available from automatic detection + features = section.get("features", { + 'energy': 0.5, + 'onset_density': 0.5, + 'brightness': 0.5, + }) + + # Estimate features from position if not available + if 'energy' not in features: + blocks = reference.get("blocks", []) + if blocks: + progress = i / max(1, len(sections) - 1) + idx = min(len(blocks) - 1, max(0, int(progress * (len(blocks) - 1)))) + features['energy'] = float(blocks[idx].get("energy_norm", 0.5)) + + roles = self._detect_roles_for_segment(features, kind) + segment_roles[f"section_{i}_{kind}"] = roles + + return segment_roles + + def detect_reference_sections(self, reference_path: str, min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: + """Automatically detect sections from a reference track with richer feature extraction.""" + if librosa is None: + raise RuntimeError("librosa no está disponible") + + path = Path(reference_path) + y, sr = librosa.load(str(path), sr=22050, mono=True) + hop_length = 512 + n_fft = _adaptive_n_fft(len(y)) + + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) + rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] + centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] + + duration = float(librosa.get_duration(y=y, sr=sr)) + + sections = self._section_detector.detect_sections( + rms, onset_env, centroid, duration, min_section_seconds + ) + + tempo = float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median) or 128) + + if len(sections) < 2 and duration > min_section_seconds * 1.5: + mid = duration / 2 + energy_first_half = float(np.mean(rms[:int(len(rms)/2)])) if len(rms) > 0 else 0.5 + energy_second_half = float(np.mean(rms[int(len(rms)/2):])) if len(rms) > 1 else 0.5 + + if energy_first_half < energy_second_half * 0.8: + sections = [ + {'kind': 'intro', 'start': 0.0, 'end': mid * 0.4, 'duration': mid * 0.4, + 'bars': max(4, int(mid * 0.4 * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, + {'kind': 'build', 'start': mid * 0.4, 'end': mid, 'duration': mid * 0.6, + 'bars': max(4, int(mid * 0.6 * tempo / 60 / 4)), 'features': {'energy': (energy_first_half + energy_second_half) / 2}}, + {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, + ] + else: + sections = [ + {'kind': 'verse', 'start': 0.0, 'end': mid, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, + {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, + 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, + ] + + prev_features = None + total_sections = len(sections) + for i, section in enumerate(sections): + sec_duration = section.get('duration', 8.0) + beats_per_second = tempo / 60.0 + beats = sec_duration * beats_per_second + bars = max(4, int(round(beats / 4.0))) + section['bars'] = bars + section['beats'] = bars * 4 + section['tempo'] = round(tempo, 1) + section['section_index'] = i + section['total_sections'] = total_sections + + start_time = float(section.get('start', 0.0)) + end_time = float(section.get('end', sec_duration)) + + # Compute richer section features inline (method was in wrong class) + duration_sec = end_time - start_time + frames_per_second = sr / hop_length + start_frame = int(start_time * frames_per_second) + end_frame = int(end_time * frames_per_second) + start_frame = max(0, min(start_frame, len(rms) - 1)) + end_frame = max(start_frame + 1, min(end_frame, len(rms))) + + section_rms = rms[start_frame:end_frame] if end_frame > start_frame else np.array([0.0]) + rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 + energy_mean = float(np.mean(section_rms)) if len(section_rms) > 0 else 0.0 + energy_peak = float(np.max(section_rms)) if len(section_rms) > 0 else 0.0 + energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) + energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) + + richer_features = { + 'energy_mean': round(energy_mean_norm, 3), + 'energy_peak': round(energy_peak_norm, 3), + 'energy_slope': 0.0, + 'spectral_centroid_mean': 0.5, + 'spectral_centroid_std': 0.0, + 'onset_rate': 0.5, + 'low_energy_ratio': 0.3, + 'high_energy_ratio': 0.3, + } + + if 'features' not in section: + section['features'] = {} + section['features'].update(richer_features) + + kind = str(section.get('kind', 'drop')).lower() + position_ratio = start_time / max(duration, 0.001) + section['features']['total_sections'] = total_sections + + # Simple confidence calculation inline + energy = section['features'].get('energy', 0.5) + onset_density = section['features'].get('onset_density', 0.5) + + # Basic confidence based on energy and position + if kind == 'intro' and position_ratio < 0.2: + confidence = 0.7 + elif kind == 'outro' and position_ratio > 0.8: + confidence = 0.7 + elif kind == 'drop' and energy > 0.6: + confidence = 0.75 + elif kind == 'build' and 0.3 < position_ratio < 0.7: + confidence = 0.65 + elif kind == 'break' and 0.4 < position_ratio < 0.8: + confidence = 0.6 + else: + confidence = 0.5 + + section['kind_confidence'] = confidence + alternatives = [] + if confidence < 0.55: + alternatives = ['drop', 'build', 'break'] + section['kind_alternatives'] = alternatives + + prev_features = section['features'] + + sections = self._validate_section_sequence(sections, duration, tempo) + + return sections + + def _validate_section_sequence(self, sections: List[Dict[str, Any]], + duration: float, tempo: float) -> List[Dict[str, Any]]: + """Validate and potentially correct section sequence for musical coherence.""" + if len(sections) < 2: + return sections + + result = [] + sequence_issues = [] + + VALID_TRANSITIONS = { + 'intro': {'verse', 'build', 'break', 'drop'}, + 'verse': {'build', 'drop', 'break', 'verse', 'outro'}, + 'build': {'drop', 'break', 'verse'}, + 'drop': {'break', 'verse', 'build', 'outro', 'drop'}, + 'break': {'build', 'drop', 'verse', 'outro'}, + 'outro': set(), + } + + PREFERRED_FIRST = {'intro', 'verse', 'build', 'break'} + PREFERRED_LAST = {'outro', 'drop', 'break'} + + for i, section in enumerate(sections): + kind = section.get('kind', 'drop') + confidence = section.get('kind_confidence', 0.5) + alternatives = section.get('kind_alternatives', []) + + section_copy = dict(section) + + if i == 0: + if kind not in PREFERRED_FIRST: + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in PREFERRED_FIRST: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'first_section_adjusted' + section_copy['original_kind'] = kind + break + elif confidence < 0.45: + section_copy['sequence_warning'] = f'first_section_is_{kind}' + + if i == len(sections) - 1: + if kind not in PREFERRED_LAST: + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in PREFERRED_LAST: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'last_section_adjusted' + section_copy['original_kind'] = kind + break + elif confidence < 0.45: + section_copy['sequence_warning'] = f'last_section_is_{kind}' + + if 0 < i < len(sections) - 1: + prev_kind = sections[i - 1].get('kind', 'drop') + next_kind = sections[i + 1].get('kind', 'drop') if i + 1 < len(sections) else None + + valid_prev = kind in VALID_TRANSITIONS.get(prev_kind, set()) + + if not valid_prev and confidence < 0.60: + transition_key = f'{prev_kind}_to_{kind}' + sequence_issues.append(transition_key) + + if alternatives: + for alt in alternatives: + if alt in VALID_TRANSITIONS.get(prev_kind, set()): + if next_kind is None or next_kind in VALID_TRANSITIONS.get(alt, set()): + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'transition_fixed' + section_copy['original_kind'] = kind + section_copy['invalid_transition'] = transition_key + break + + if kind == 'build': + next_kind = sections[i + 1].get('kind', '') if i < len(sections) - 1 else None + if next_kind and next_kind not in ('drop', 'break', 'verse'): + next_confidence = sections[i + 1].get('kind_confidence', 0.5) + if next_confidence < 0.60: + section_copy['build_transition_warning'] = f'build_followed_by_{next_kind}' + + if kind == 'drop': + features = section.get('features', {}) + energy = features.get('energy', 0.5) + if energy < 0.50: + section_copy['drop_energy_warning'] = f'drop_has_low_energy_{energy:.2f}' + if confidence < 0.55 and alternatives: + for alt in alternatives: + if alt in {'verse', 'build'}: + section_copy['kind'] = alt + section_copy['sequence_correction'] = 'low_energy_drop_reclassified' + section_copy['original_kind'] = 'drop' + break + + result.append(section_copy) + + if sequence_issues: + result[0]['sequence_issues'] = sequence_issues[:5] + + return result + + def _get_section_variant(self, section_kind: str, section_name: str = "") -> str: + """ + Determina la variante apropiada para una sección. + + Retorna un string como 'sparse', 'dense', 'full', etc. + """ + kind_lower = section_kind.lower() + name_lower = section_name.lower() + + # Detectar variantes especiales por nombre + if 'peak' in name_lower or 'main' in name_lower: + return 'peak' + if 'minimal' in name_lower: + return 'minimal' + if 'atmos' in name_lower: + return 'atmospheric' + + # Usar defaults por tipo + return SECTION_VARIANTS.get(kind_lower, ['standard'])[0] + + def _select_variant_samples(self, + base_samples: List[Any], + role: str, + section_variant: str, + target_key: str = None, + target_bpm: float = None) -> List[Any]: + """ + Selecciona samples apropiados para una variante de sección. + + Filtra y reordena base_samples según la variante: + - 'sparse': prefiere samples más ligeros/simples + - 'dense': prefiere samples más complejos + - 'full': usa samples principales + - 'minimal': usa samples más sutiles + """ + if not base_samples: + return base_samples + + # Por defecto, retornar sin cambios + if section_variant == 'standard': + return base_samples + + variant_samples = [] + + for sample in base_samples: + # Get sample name from the match dict + if isinstance(sample, dict): + sample_name = sample.get('file_name', '') + else: + sample_name = str(sample) + + name_lower = sample_name.lower() + + # Variant sparse/minimal: buscar keywords sutiles + if section_variant in ['sparse', 'minimal', 'atmospheric', 'fading']: + if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'simple', 'minimal', 'clean', 'thin']): + variant_samples.insert(0, sample) # Prioridad alta + elif any(kw in name_lower for kw in ['heavy', 'full', 'busy', 'complex', 'big', 'thick']): + continue # Skip para variantes sutiles + else: + variant_samples.append(sample) + + # Variant dense/full/peak: buscar keywords ricos + elif section_variant in ['dense', 'full', 'peak', 'building']: + if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main', 'thick']): + variant_samples.insert(0, sample) # Prioridad alta + elif any(kw in name_lower for kw in ['minimal', 'subtle', 'light', 'thin']): + continue # Skip para variantes ricas + else: + variant_samples.append(sample) + + else: + variant_samples.append(sample) + + # Si no quedan samples después del filtro, usar originals + return variant_samples if variant_samples else base_samples + + def _get_variant_samples_for_section(self, + base_samples: List[Any], + role: str, + section_kind: str, + section_name: str, + target_key: str = None, + target_bpm: float = None, + max_variants: int = 3) -> Dict[str, List[Any]]: + """ + Selecciona samples DIFERENTES para diferentes secciones de un mismo rol. + + Retorna un dict mapping section_key -> list of samples. + + Para roles variante (perc, top_loop, etc.), esto retorna samples distintos + para intro/verse/build/drop/break/outro cuando es posible. + """ + # Roles que pueden tener variación real + variant_roles = ['perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos'] + + if role not in variant_roles or not base_samples or len(base_samples) < 3: + # No hay suficiente pool para variación + return {'all': base_samples} + + section_map = {} + + # Variantes por tipo de sección + section_types = { + 'intro': ['minimal', 'sparse'], + 'verse': ['standard', 'light'], + 'build': ['building', 'adding'], + 'drop': ['full', 'peak', 'rich'], + 'break': ['sparse', 'atmospheric'], + 'outro': ['fading', 'minimal'] + } + + # Para cada sección, seleccionar samples con preferencias diferentes + section_key = f"{section_kind}_{section_name}" + + # Determinar preferencia para esta sección + variants = section_types.get(section_kind.lower(), ['standard']) + preference = variants[0] if variants else 'standard' + + # Filtrar samples según preferencia + variant_samples = [] + remaining_samples = list(base_samples) + + for sample in remaining_samples: + # Get sample name from the match dict + if isinstance(sample, dict): + sample_name = sample.get('file_name', '') + else: + sample_name = str(sample) + + name_lower = sample_name.lower() + + # Para sparse/minimal: buscar keywords ligeros + if preference in ['minimal', 'sparse', 'atmospheric']: + if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'minimal', 'clean', 'atmos']): + variant_samples.append(sample) + elif any(kw in name_lower for kw in ['heavy', 'hard', 'full', 'big']): + continue + + # Para full/peak: buscar keywords ricos + elif preference in ['full', 'peak', 'rich', 'building']: + if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main']): + variant_samples.append(sample) + elif any(kw in name_lower for kw in ['minimal', 'subtle']): + continue + + else: + variant_samples.append(sample) + + # Si no encontramos suficientes, usar del pool original + if len(variant_samples) < 2: + variant_samples = base_samples[:max_variants] + + section_map[section_key] = variant_samples[:max_variants] + + return section_map + + def build_arrangement_plan(self, reference_path: str, sections: List[Dict[str, Any]], + project_bpm: float, project_key: str, + variant_seed: Optional[int] = None) -> Dict[str, Any]: + # Reset family tracking for new generation + self.reset_family_tracking() + + result = self.match_assets(reference_path) + reference = result["reference"] + matches = result["matches"] + + # Auto-detect sections if not provided or enhance existing ones + if not sections: + sections = self.detect_reference_sections(reference_path) + + offsets = self._section_offsets(sections) + rng = random.Random(variant_seed if variant_seed is not None else random.SystemRandom().randint(1, 10**9)) + + # Analyze roles per segment + segment_roles = self._analyze_segment_roles(reference, sections) + + used_paths: set = set() + used_families: set = set() + selection_order = [ + "kick", + "snare", + "hat", + "bass_loop", + "perc_loop", + "top_loop", + "synth_loop", + "vocal_loop", + "crash_fx", + "fill_fx", + "snare_roll", + "atmos_fx", + "vocal_shot", + ] + selected: Dict[str, Optional[Dict[str, Any]]] = {} + for role in selection_order: + selected[role] = self._select_distinct_candidate(role, matches.get(role, []), rng, used_paths, used_families) + + perc_candidates = [ + item for item in matches.get("perc_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("perc_loop")) + ] + perc_alt = self._select_distinct_candidate("perc_loop", perc_candidates, rng, used_paths, used_families) if perc_candidates else None + synth_candidates = [ + item for item in matches.get("synth_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("synth_loop")) + ] + synth_alt = self._select_distinct_candidate("synth_loop", synth_candidates, rng, used_paths, used_families) if synth_candidates else None + vocal_candidates = [ + item for item in matches.get("vocal_loop", []) + if self._candidate_path(item) != self._candidate_path(selected.get("vocal_loop")) + ] + vocal_alt = self._select_distinct_candidate("vocal_loop", vocal_candidates, rng, used_paths, used_families) if vocal_candidates else None + + def add_range(target: List[Tuple[float, Dict]], start: float, end: float, step: float, offset: float = 0.0, sample: Dict = None): + if sample is None: + return + cursor = start + offset + while cursor < end - 0.01: + target.append((round(float(cursor), 3), sample)) + cursor += step + + def add_hit(target: List[Tuple[float, Dict]], position: float, sample: Dict = None): + if position >= 0.0 and sample is not None: + target.append((round(float(position), 3), sample)) + + kick_positions: List[Tuple[float, Dict]] = [] + snare_positions: List[Tuple[float, Dict]] = [] + hat_positions: List[Tuple[float, Dict]] = [] + bass_positions: List[Tuple[float, Dict]] = [] + perc_positions: List[Tuple[float, Dict]] = [] + perc_alt_positions: List[Tuple[float, Dict]] = [] + top_loop_positions: List[Tuple[float, Dict]] = [] + synth_positions: List[Tuple[float, Dict]] = [] + synth_peak_positions: List[Tuple[float, Dict]] = [] + vocal_positions: List[Tuple[float, Dict]] = [] + vocal_build_positions: List[Tuple[float, Dict]] = [] + vocal_peak_positions: List[Tuple[float, Dict]] = [] + crash_positions: List[Tuple[float, Dict]] = [] + fill_positions: List[Tuple[float, Dict]] = [] + snare_roll_positions: List[Tuple[float, Dict]] = [] + atmos_positions: List[Tuple[float, Dict]] = [] + vocal_shot_positions: List[Tuple[float, Dict]] = [] + + bass_step = self._loop_step_beats(selected.get("bass_loop"), project_bpm, 16.0) + perc_step = self._loop_step_beats(selected.get("perc_loop"), project_bpm, 16.0) + perc_alt_step = self._loop_step_beats(perc_alt, project_bpm, 8.0) + top_loop_step = self._loop_step_beats(selected.get("top_loop"), project_bpm, 8.0) + synth_step = self._loop_step_beats(selected.get("synth_loop"), project_bpm, 16.0) + vocal_step = self._loop_step_beats(selected.get("vocal_loop"), project_bpm, 8.0) + vocal_alt_step = self._loop_step_beats(vocal_alt, project_bpm, 8.0) + synth_alt_step = self._loop_step_beats(synth_alt, project_bpm, 8.0) + atmos_step = self._loop_step_beats(selected.get("atmos_fx"), project_bpm, 16.0) + + # Store section-specific samples for roles eligible for variation + section_samples: Dict[int, Dict[str, Optional[Dict[str, Any]]]] = {} + + for index, (section, start, end) in enumerate(offsets): + kind = str(section.get("kind", "drop")).lower() + section_name = str(section.get("name", "")).lower() + midpoint = (start + end) / 2.0 + progress = midpoint / max(1.0, offsets[-1][2]) + energy = self._section_energy(reference, progress) + is_peak = "peak" in section_name or energy > 0.82 + is_vocal = "vocal" in section_name + span = max(4.0, end - start) + has_next_section = index < len(offsets) - 1 + next_section = offsets[index + 1][0] if has_next_section else {} + next_kind = str(next_section.get("kind", "")).lower() + next_name = str(next_section.get("name", "")).lower() + transition_into_drop = next_kind == "drop" or "drop" in next_name or "peak" in next_name + transition_is_vocal = "vocal" in next_name + tail_hit = max(start, end - min(4.0, span / 2.0)) + roll_start = max(start, end - min(8.0, span)) + + # Apply section variation for eligible roles + section_variant = self._get_section_variant(kind, section.get('name', '')) + section_samples[index] = {} + + # Map roles to their match lists and global selections + role_match_map = { + 'perc': ('perc_loop', matches.get('perc_loop', []), selected.get('perc_loop')), + 'perc_alt': ('perc_loop', matches.get('perc_loop', []), perc_alt), + 'top_loop': ('top_loop', matches.get('top_loop', []), selected.get('top_loop')), + 'vocal_shot': ('vocal_shot', matches.get('vocal_shot', []), selected.get('vocal_shot')), + 'synth_peak': ('synth_loop', matches.get('synth_loop', []), synth_alt), + 'atmos': ('atmos_fx', matches.get('atmos_fx', []), selected.get('atmos_fx')), + } + + for var_role, (match_role, match_list, fallback_sample) in role_match_map.items(): + if var_role in SECTION_VARIATION_ROLES and match_list and section_variant != 'standard': + # Apply variant filtering with section-specific samples + section_samples_map = self._get_variant_samples_for_section( + match_list, + var_role, + kind, + section.get('name', ''), + target_key=project_key, + target_bpm=project_bpm + ) + + # Get section-specific samples for this role + section_key = f"{kind}_{section.get('name', '')}" + specific_samples = section_samples_map.get(section_key, match_list) + + # Use specific_samples for selection + samples_to_use = specific_samples if specific_samples else match_list + + if samples_to_use and samples_to_use != match_list: + # Select from section-specific samples, avoiding already used paths + section_used_paths = used_paths.copy() + section_sample = self._select_distinct_candidate( + match_role, + samples_to_use, + rng, + section_used_paths, + used_families + ) + + if section_sample: + # Get the actual file path for logging + sample_path = section_sample.get('file_path', section_sample.get('file_name', 'unknown')) + logger.debug("SECTION_VARIANT_REAL: role '%s' using %d specific samples for section '%s' (vs %d base) - selected: %s", + var_role, len(samples_to_use), section.get('name'), len(match_list), sample_path) + section_samples[index][var_role] = section_sample + else: + # Fallback to global selection + section_samples[index][var_role] = fallback_sample + else: + # No filtering applied or no samples after filter, use global + section_samples[index][var_role] = fallback_sample + else: + # Not eligible for variation or no variant, use global + section_samples[index][var_role] = fallback_sample + + # Helper to get the right sample for a role in this section + def get_sample(role: str, fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Get section-specific sample if available, otherwise fallback.""" + return section_samples[index].get(role, fallback) + + atmos_sample = get_sample('atmos', selected.get("atmos_fx")) + if atmos_sample and kind in {"intro", "break", "outro"}: + add_range(atmos_positions, start, end, max(8.0, atmos_step), sample=atmos_sample) + elif atmos_sample and is_vocal and span >= 8.0: + add_hit(atmos_positions, max(start, end - 8.0), sample=atmos_sample) + + if kind == 'intro': + add_range(kick_positions, start, end, 2.0 if energy < 0.55 else 1.0, sample=selected.get("kick")) + add_range(hat_positions, start, end, 1.0, 0.5, sample=selected.get("hat")) + if selected.get("top_loop") and energy > 0.5: + add_range(top_loop_positions, start + min(4.0, span / 2.0), end, top_loop_step, 0.0, sample=get_sample('top_loop', selected.get("top_loop"))) + elif kind == 'break': + add_range(kick_positions, start, end, 4.0, sample=selected.get("kick")) + add_range(snare_positions, start + 3.0, end, 4.0, sample=selected.get("snare")) + if selected.get("perc_loop"): + perc_sample = get_sample('perc_alt', perc_alt) if perc_alt else get_sample('perc', selected.get("perc_loop")) + add_range(perc_alt_positions if perc_alt else perc_positions, start, end, perc_alt_step if perc_alt else perc_step, sample=perc_sample) + if vocal_alt and (is_vocal or energy > 0.6): + add_range(vocal_build_positions, start + max(0.0, span - 8.0), end, vocal_alt_step, sample=vocal_alt) + if selected.get("fill_fx") and has_next_section: + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + if selected.get("snare_roll") and has_next_section: + add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) + elif kind == 'build': + add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) + add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) + add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) + if selected.get("bass_loop"): + add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) + if selected.get("perc_loop"): + add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) + if selected.get("top_loop"): + add_range(top_loop_positions, start + 4.0, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) + if selected.get("vocal_loop") and is_vocal: + add_range(vocal_positions, start, end, vocal_step, sample=selected.get("vocal_loop")) + if vocal_alt and (is_vocal or energy > 0.58): + add_range(vocal_build_positions, start, end, vocal_alt_step, 0.0, sample=vocal_alt) + if selected.get("synth_loop") and energy > 0.62: + add_range(synth_positions, max(start, end - max(8.0, synth_step)), end, synth_step, sample=selected.get("synth_loop")) + if selected.get("snare_roll"): + add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) + if selected.get("fill_fx"): + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + if transition_into_drop and selected.get("crash_fx"): + add_hit(crash_positions, end, sample=selected.get("crash_fx")) + else: + add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) + add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) + add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) + if selected.get("bass_loop"): + add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) + if selected.get("perc_loop"): + add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) + if selected.get("top_loop"): + add_range(top_loop_positions, start, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) + if perc_alt and ("peak" in str(section.get("name", "")).lower() or energy > 0.82): + add_range(perc_alt_positions, start, end, perc_alt_step, sample=get_sample('perc_alt', perc_alt)) + if selected.get("synth_loop") and ("drop b" in section_name or is_peak or kind == 'drop'): + add_range(synth_positions, start, end, synth_step, sample=selected.get("synth_loop")) + if synth_alt and is_peak: + add_range(synth_peak_positions, start + min(4.0, span / 4.0), end, synth_alt_step, sample=get_sample('synth_peak', synth_alt)) + if selected.get("vocal_loop") and ("drop b" in section_name or is_peak): + add_range(vocal_positions, start + 4.0, end, vocal_step, sample=selected.get("vocal_loop")) + if vocal_alt and is_peak: + add_range(vocal_peak_positions, start, end, vocal_alt_step, sample=vocal_alt) + if selected.get("crash_fx") and index > 0: + add_hit(crash_positions, start, sample=selected.get("crash_fx")) + if selected.get("fill_fx") and has_next_section and next_kind != "outro": + add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) + + vocal_shot_sample = get_sample('vocal_shot', selected.get("vocal_shot")) + if vocal_shot_sample and (is_peak or transition_is_vocal): + add_hit(vocal_shot_positions, min(end - 1.0, start + 4.0), sample=vocal_shot_sample) + if span >= 16.0: + add_hit(vocal_shot_positions, min(end - 1.0, start + span / 2.0), sample=vocal_shot_sample) + + layers: List[Dict[str, Any]] = [] + + def add_layer(name: str, asset: Optional[Dict[str, Any]], positions: List[Tuple[float, Dict]], + color: int, volume: float): + """Add one or more layers for positions grouped by sample.""" + if not positions: + return + + # Group positions by sample + positions_by_sample: Dict[str, List[float]] = {} + sample_info: Dict[str, Dict[str, Any]] = {} + + for pos, sample in positions: + if sample is None: + continue + sample_path = sample.get("path", "") + if sample_path not in positions_by_sample: + positions_by_sample[sample_path] = [] + sample_info[sample_path] = sample + positions_by_sample[sample_path].append(pos) + + # If no asset provided but positions exist, use the first sample + if asset is None and positions_by_sample: + first_sample_path = next(iter(positions_by_sample)) + asset = sample_info[first_sample_path] + + # If all positions use the same sample (or asset is provided), create single layer + if asset and (len(positions_by_sample) == 1 or asset.get("path") in positions_by_sample): + asset_positions = positions_by_sample.get(asset.get("path", ""), [p for p, _ in positions]) + if asset_positions: + adj_vol = volume + rms = asset.get("rms_energy", 0.0) + if rms > 0.0: + adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) + + layers.append({ + "name": name, + "file_path": asset["path"], + "positions": sorted(set(asset_positions)), + "color": color, + "volume": round(adj_vol, 3), + "source": asset.get("file_name", ""), + }) + else: + # Multiple samples - create layers with variant names + for i, (sample_path, pos_list) in enumerate(positions_by_sample.items()): + sample = sample_info[sample_path] + variant_name = sample.get("file_name", "") + + adj_vol = volume + rms = sample.get("rms_energy", 0.0) + if rms > 0.0: + adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) + + # Create variant suffix based on sample characteristics + if i > 0: + layer_name = f"{name} ({variant_name[:20]})" + else: + layer_name = name + + layers.append({ + "name": layer_name, + "file_path": sample_path, + "positions": sorted(set(pos_list)), + "color": color, + "volume": round(adj_vol, 3), + "source": variant_name, + }) + + add_layer("AUDIO KICK", selected.get("kick"), kick_positions, 10, 0.86) + add_layer("AUDIO CLAP", selected.get("snare"), snare_positions, 45, 0.72) + add_layer("AUDIO HAT", selected.get("hat"), hat_positions, 5, 0.58) + add_layer("AUDIO BASS LOOP", selected.get("bass_loop"), bass_positions, 30, 0.76) + add_layer("AUDIO PERC MAIN", selected.get("perc_loop"), perc_positions, 20, 0.68) + add_layer("AUDIO PERC ALT", perc_alt, perc_alt_positions, 22, 0.62) + add_layer("AUDIO TOP LOOP", selected.get("top_loop") or perc_alt or selected.get("perc_loop"), top_loop_positions, 24, 0.52) + add_layer("AUDIO SYNTH LOOP", selected.get("synth_loop"), synth_positions, 50, 0.52) + add_layer("AUDIO SYNTH PEAK", synth_alt or selected.get("synth_loop"), synth_peak_positions, 52, 0.48) + add_layer("AUDIO VOCAL LOOP", selected.get("vocal_loop"), vocal_positions, 40, 0.6) + add_layer("AUDIO VOCAL BUILD", vocal_alt or selected.get("vocal_loop"), vocal_build_positions, 42, 0.54) + add_layer("AUDIO VOCAL PEAK", vocal_alt or selected.get("vocal_loop"), vocal_peak_positions, 43, 0.58) + add_layer("AUDIO CRASH FX", selected.get("crash_fx"), crash_positions, 26, 0.5) + add_layer("AUDIO TRANSITION FILL", selected.get("fill_fx") or selected.get("snare_roll"), fill_positions, 28, 0.56) + add_layer("AUDIO SNARE ROLL", selected.get("snare_roll"), snare_roll_positions, 27, 0.54) + add_layer("AUDIO ATMOS", selected.get("atmos_fx"), atmos_positions, 54, 0.44) + add_layer("AUDIO VOCAL SHOT", selected.get("vocal_shot"), vocal_shot_positions, 41, 0.52) + + # Compute remake quality metrics + remake_quality = self._compute_remake_quality_metrics( + sections, selected, sections + ) + + # Build section energy profile for generator + section_energy_profile = [] + for section in sections: + features = section.get('features', {}) + section_energy_profile.append({ + 'kind': section.get('kind', 'drop'), + 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), + 'energy_peak': features.get('energy_peak', 0.5), + 'energy_slope': features.get('energy_slope', 0.0), + 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), + 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), + 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), + 'low_energy_ratio': features.get('low_energy_ratio', 0.0), + 'high_energy_ratio': features.get('high_energy_ratio', 0.0), + 'kind_confidence': section.get('kind_confidence', 0.5), + }) + + return { + "reference": { + "path": reference.get("path"), + "file_name": reference.get("file_name"), + "tempo": reference.get("tempo"), + "key": reference.get("key") or project_key, + "device": self.device_name, + "variant_seed": variant_seed, + }, + "sections": sections, + "segment_roles": segment_roles, + "layers": layers, + "matches": selected, + "section_samples": section_samples, + "section_energy_profile": section_energy_profile, + "remake_quality": remake_quality, + } + + def _compute_remake_quality_metrics( + self, + sections: List[Dict[str, Any]], + selected: Dict[str, Optional[Dict[str, Any]]], + reference_sections: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Compute per-section quality scores for how well selected samples match reference character. + + Metrics included: + - Energy profile similarity + - Spectral characteristic similarity + - Rhythmic density comparison + - Low-end presence matching + - High-end brightness matching + + Uses already-computed data - no new librosa calls. + """ + section_scores = [] + + energy_profile_scores = [] + spectral_similarity_scores = [] + rhythmic_density_scores = [] + low_end_presence_scores = [] + high_end_brightness_scores = [] + + for i, section in enumerate(sections): + kind = str(section.get('kind', 'drop')).lower() + features = section.get('features', {}) + section_match_score = 0.5 + weak_roles = [] + + ref_energy_mean = features.get('energy_mean', features.get('energy', 0.5)) + _ = features.get('energy_peak', ref_energy_mean) + ref_energy_slope = features.get('energy_slope', 0.0) + ref_onset_rate = features.get('onset_rate', features.get('onset_density', 0.5)) + ref_low_ratio = features.get('low_energy_ratio', 0.0) + ref_high_ratio = features.get('high_energy_ratio', 0.0) + ref_spectral_centroid = features.get('spectral_centroid_mean', features.get('brightness', 0.5)) + ref_spectral_std = features.get('spectral_centroid_std', 0.0) + + energy_profile_score = 0.5 + spectral_similarity_score = 0.5 + rhythmic_density_score = 0.5 + low_end_presence_score = 0.5 + high_end_brightness_score = 0.5 + + selected_samples_energy = [] + selected_samples_centroid = [] + selected_samples_onset = [] + selected_samples_low_energy = 0.0 + selected_samples_high_energy = 0.0 + + for role in ['kick', 'snare', 'hat', 'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx']: + sample = selected.get(role) + if sample: + rms = float(sample.get('rms_mean', sample.get('rms_energy', 0.5)) or 0.5) + centroid = float(sample.get('spectral_centroid', 5000) or 5000) + onset = float(sample.get('onset_mean', sample.get('onset_rate', 3)) or 3) + + selected_samples_energy.append(rms) + selected_samples_centroid.append(centroid) + selected_samples_onset.append(onset) + + if centroid < 300: + selected_samples_low_energy += rms + if centroid > 4000: + selected_samples_high_energy += rms + + if selected_samples_energy: + avg_energy = sum(selected_samples_energy) / len(selected_samples_energy) + energy_diff = abs(avg_energy - ref_energy_mean) + energy_profile_score = max(0.0, 1.0 - energy_diff * 2.0) + + if ref_energy_slope > 0.1: + build_roles = ['snare_roll', 'fill_fx', 'hat'] + build_energy = sum( + float(selected.get(r, {}).get('rms_mean', 0) or 0) + for r in build_roles if selected.get(r) + ) + if build_energy > 0.3: + energy_profile_score = min(1.0, energy_profile_score + 0.15) + + if selected_samples_centroid: + avg_centroid_norm = sum(selected_samples_centroid) / len(selected_samples_centroid) / 10000.0 + ref_centroid_norm = ref_spectral_centroid + centroid_diff = abs(avg_centroid_norm - ref_centroid_norm) + spectral_similarity_score = max(0.0, 1.0 - centroid_diff) + + if ref_spectral_std > 0.3: + centroid_variance = 0.0 + if len(selected_samples_centroid) > 1: + centroid_variance = float(np.std(selected_samples_centroid)) / 10000.0 + if centroid_variance > 0.1: + spectral_similarity_score = min(1.0, spectral_similarity_score + 0.1) + + if selected_samples_onset: + avg_onset_norm = sum(selected_samples_onset) / len(selected_samples_onset) / 10.0 + ref_onset_norm = ref_onset_rate + onset_diff = abs(avg_onset_norm - ref_onset_norm) + rhythmic_density_score = max(0.0, 1.0 - onset_diff) + + if ref_onset_rate > 0.5: + perc_onset = float(selected.get('perc_loop', {}).get('onset_mean', 0) or 0) + top_onset = float(selected.get('top_loop', {}).get('onset_mean', 0) or 0) + hat_onset = float(selected.get('hat', {}).get('onset_mean', 0) or 0) + if perc_onset > 3 or top_onset > 3 or hat_onset > 3: + rhythmic_density_score = min(1.0, rhythmic_density_score + 0.15) + + bass_match = selected.get('bass_loop') + kick_match = selected.get('kick') + if bass_match or kick_match: + bass_centroid = float(bass_match.get('spectral_centroid', 500) or 500) if bass_match else 500 + kick_centroid = float(kick_match.get('spectral_centroid', 300) or 300) if kick_match else 300 + low_centroid_avg = (bass_centroid + kick_centroid) / 2 + + if ref_low_ratio > 0.3: + if low_centroid_avg < 1500: + low_end_presence_score = 0.85 + (ref_low_ratio * 0.15) + elif low_centroid_avg < 2500: + low_end_presence_score = 0.65 + else: + low_end_presence_score = 0.35 + weak_roles.append('bass_loop') + else: + low_end_presence_score = 0.7 + else: + if ref_low_ratio > 0.35: + low_end_presence_score = 0.3 + weak_roles.append('bass_loop') + + hat_match = selected.get('hat') + top_match = selected.get('top_loop') + synth_match = selected.get('synth_loop') + if hat_match or top_match or synth_match: + high_centroids = [] + if hat_match: + high_centroids.append(float(hat_match.get('spectral_centroid', 6000) or 6000)) + if top_match: + high_centroids.append(float(top_match.get('spectral_centroid', 5000) or 5000)) + if synth_match: + high_centroids.append(float(synth_match.get('spectral_centroid', 4000) or 4000)) + + avg_high_centroid = sum(high_centroids) / len(high_centroids) if high_centroids else 5000 + + if ref_high_ratio > 0.25: + if avg_high_centroid > 7000: + high_end_brightness_score = 0.85 + (ref_high_ratio * 0.15) + elif avg_high_centroid > 5000: + high_end_brightness_score = 0.65 + else: + high_end_brightness_score = 0.4 + weak_roles.append('hat') + else: + high_end_brightness_score = 0.7 + else: + if ref_high_ratio > 0.3: + high_end_brightness_score = 0.35 + weak_roles.append('hat') + + if kind == 'drop': + if bass_match and ref_energy_mean > 0.6: + section_match_score += 0.08 + if hat_match and ref_onset_rate > 0.4: + section_match_score += 0.05 + elif kind == 'break': + atmos_match = selected.get('atmos_fx') + if atmos_match and ref_energy_mean < 0.45: + section_match_score += 0.10 + low_end_presence_score = min(1.0, low_end_presence_score + 0.1) + elif kind == 'build': + snare_roll_match = selected.get('snare_roll') + fill_match = selected.get('fill_fx') + if snare_roll_match and ref_energy_slope > 0.05: + section_match_score += 0.08 + rhythmic_density_score = min(1.0, rhythmic_density_score + 0.1) + if fill_match: + section_match_score += 0.05 + elif kind == 'intro': + atmos_match = selected.get('atmos_fx') + if atmos_match: + section_match_score += 0.05 + elif kind == 'outro': + atmos_match = selected.get('atmos_fx') + if atmos_match and ref_energy_mean < 0.4: + section_match_score += 0.05 + + energy_profile_scores.append(energy_profile_score) + spectral_similarity_scores.append(spectral_similarity_score) + rhythmic_density_scores.append(rhythmic_density_score) + low_end_presence_scores.append(low_end_presence_score) + high_end_brightness_scores.append(high_end_brightness_score) + + combined_score = ( + energy_profile_score * 0.20 + + spectral_similarity_score * 0.20 + + rhythmic_density_score * 0.20 + + low_end_presence_score * 0.20 + + high_end_brightness_score * 0.20 + ) + section_match_score = max(section_match_score, combined_score) + section_match_score = max(0.0, min(1.0, section_match_score)) + + section_scores.append({ + 'kind': kind, + 'score': round(section_match_score, 3), + 'weak_roles': weak_roles, + 'energy_profile_score': round(energy_profile_score, 3), + 'spectral_similarity_score': round(spectral_similarity_score, 3), + 'rhythmic_density_score': round(rhythmic_density_score, 3), + 'low_end_presence_score': round(low_end_presence_score, 3), + 'high_end_brightness_score': round(high_end_brightness_score, 3), + }) + + overall_score = sum(s['score'] for s in section_scores) / max(len(section_scores), 1) + + avg_energy_profile = sum(energy_profile_scores) / max(len(energy_profile_scores), 1) + avg_spectral = sum(spectral_similarity_scores) / max(len(spectral_similarity_scores), 1) + avg_rhythmic = sum(rhythmic_density_scores) / max(len(rhythmic_density_scores), 1) + avg_low_end = sum(low_end_presence_scores) / max(len(low_end_presence_scores), 1) + avg_high_end = sum(high_end_brightness_scores) / max(len(high_end_brightness_scores), 1) + + improvement_hints = [] + for section_score in section_scores: + for role in section_score.get('weak_roles', []): + hint = f"{section_score['kind']} section needs better {role} samples" + if hint not in improvement_hints: + improvement_hints.append(hint) + + if avg_energy_profile < 0.5: + improvement_hints.append("Overall energy profile mismatch - adjust sample dynamics") + if avg_spectral < 0.5: + improvement_hints.append("Spectral characteristics differ - check brightness/texture match") + if avg_rhythmic < 0.5: + improvement_hints.append("Rhythmic density mismatch - adjust percussive element selection") + if avg_low_end < 0.5: + improvement_hints.append("Low-end presence weak - select bass/kick with more sub energy") + if avg_high_end < 0.5: + improvement_hints.append("High-end brightness lacking - select brighter hat/top samples") + + return { + 'remake_score': round(overall_score, 3), + 'section_scores': [ + { + 'kind': s['kind'], + 'score': s['score'], + 'weak_roles': s['weak_roles'], + } + for s in section_scores + ], + 'improvement_hints': improvement_hints[:10], + 'metric_averages': { + 'energy_similarity': round(avg_energy_profile, 3), + 'spectral_similarity': round(avg_spectral, 3), + 'rhythmic_density': round(avg_rhythmic, 3), + 'low_end_match': round(avg_low_end, 3), + 'high_end_match': round(avg_high_end, 3), + }, + 'metrics_detail': { + 'energy_similarity': { + 'description': 'RMS energy distribution comparison between selected samples and reference section energy', + 'range': '0.0-1.0, higher is better', + 'weight': 0.22, + 'factors': ['average RMS match', 'energy slope for builds', 'peak energy variance'], + }, + 'spectral_similarity': { + 'description': 'Spectral centroid and variance matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.18, + 'factors': ['centroid mean match', 'centroid variance match'], + }, + 'rhythmic_density': { + 'description': 'Onset rate comparison between selected samples and reference', + 'range': '0.0-1.0, higher is better', + 'weight': 0.22, + 'factors': ['onset rate match', 'percussive element density'], + }, + 'low_end_match': { + 'description': 'Sub-bass and low frequency content presence matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.20, + 'factors': ['bass spectral centroid', 'kick spectral centroid', 'low frequency RMS'], + }, + 'high_end_match': { + 'description': 'High frequency brightness and air content matching', + 'range': '0.0-1.0, higher is better', + 'weight': 0.18, + 'factors': ['hat spectral centroid', 'top loop brightness', 'synth high frequency content'], + }, + }, + } + + +def export_segment_rag_manifest( + manifest: List[Dict[str, Any]], + output_path: Path, + format: str = "json", +) -> None: + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if format == "json": + output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return + + cached = [item for item in manifest if item.get("cached")] + built = [item for item in manifest if not item.get("cached")] + total_cached_segments = sum(int(item.get("segments", 0) or 0) for item in cached) + total_built_segments = sum(int(item.get("segments", 0) or 0) for item in built) + + lines = [ + "# Segment RAG Index Manifest", + "", + f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}", + f"Total Files: {len(manifest)}", + "", + "## Summary", + "", + f"- Cached (reused): {len(cached)}", + f"- Built (analyzed): {len(built)}", + f"- Cached segments: {total_cached_segments}", + f"- Built segments: {total_built_segments}", + ] + + if cached: + lines.extend(["", "## Cached Files (Reused)", ""]) + for item in sorted(cached, key=lambda value: value.get("file_name", "").lower()): + lines.append(f"- **{item.get('file_name', 'unknown')}**") + lines.append(f" - Roles: {', '.join(item.get('roles', []))}") + lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") + + if built: + lines.extend(["", "## Built Files (Analyzed)", ""]) + for item in sorted(built, key=lambda value: value.get("file_name", "").lower()): + lines.append(f"- **{item.get('file_name', 'unknown')}**") + lines.append(f" - Roles: {', '.join(item.get('roles', []))}") + lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") + + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _get_segment_rag_status(library_dir: Path) -> Dict[str, Any]: + """ + Get status of the segment RAG cache with human-readable metadata. + + For each cache file, tries to recover metadata from: + 1. Embedded metadata in the cache file (new format) + 2. indexing_state.json lookup (backfill source) + + Returns stats about metadata coverage and human-readable names. + """ + cache_dir = library_dir / ".segment_rag" + + if not cache_dir.exists(): + return { + "cache_dir": str(cache_dir), + "cache_files": 0, + "total_segments": 0, + "status": "not_built" + } + + cache_files = list(cache_dir.glob("*.json.gz")) + total_segments = 0 + role_coverage: Dict[str, int] = defaultdict(int) + entries: List[Dict[str, Any]] = [] + state_path = cache_dir / "indexing_state.json" + state_payload: Dict[str, Any] = {} + if state_path.exists(): + try: + state_payload = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + state_payload = {} + indexed_entries = state_payload.get("indexed_entries", {}) or {} + + # Build lookup by cache_prefix for state entries + by_prefix: Dict[str, Dict[str, Any]] = {} + for entry in indexed_entries.values(): + cache_prefix = entry.get("cache_prefix") + if cache_prefix: + by_prefix[cache_prefix] = entry + + # Track metadata coverage + files_with_embedded_metadata = 0 + files_with_state_metadata = 0 + files_without_metadata = 0 + + for cache_file in cache_files: + try: + with gzip.open(cache_file, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + if isinstance(payload, list): + segments = payload + metadata = {} + elif isinstance(payload, dict): + segments = payload.get("segments", []) or [] + metadata = payload.get("metadata", {}) or {} + else: + segments = [] + metadata = {} + total_segments += len(segments) + + # Extract cache prefix from filename (format: {path_key}__{fingerprint}__{windows}__{duration}.json.gz) + cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem + cache_prefix = cache_stem.rsplit("__", 1)[0] + + # Look up metadata from state file + state_entry = by_prefix.get(cache_prefix, {}) + + # Determine metadata source + has_embedded = bool(metadata) + has_state = bool(state_entry) + + if has_embedded: + files_with_embedded_metadata += 1 + elif has_state: + files_with_state_metadata += 1 + else: + files_without_metadata += 1 + + # Merge metadata: prefer embedded, fallback to state + file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_file.name + file_path = metadata.get("path") or state_entry.get("path") or "" + roles = metadata.get("roles") or state_entry.get("roles") or [] + + # Determine if the name is human-readable (not just a hash) + is_hash_name = len(cache_stem.split("__")[0]) == 16 and all(c in "0123456789abcdef" for c in cache_stem.split("__")[0]) + has_human_name = file_name != cache_file.name and not is_hash_name + + for role in roles: + if role: + role_coverage[role] += len(segments) + + mtime = cache_file.stat().st_mtime + + entries.append({ + "file_name": file_name, + "path": file_path, + "segments": len(segments), + "mtime": mtime, + "cache_file": cache_file.name, + "roles": roles, + "has_embedded_metadata": has_embedded, + "has_human_readable_name": has_human_name, + }) + except Exception: + logger.debug("Failed to inspect segment cache %s", cache_file, exc_info=True) + + entries.sort(key=lambda item: item["mtime"], reverse=True) + + # Calculate cache size + cache_size_bytes = sum(f.stat().st_size for f in cache_files) + cache_size_mb = round(cache_size_bytes / (1024 * 1024), 2) + + return { + "cache_dir": str(cache_dir), + "cache_files": len(cache_files), + "total_segments": total_segments, + "role_coverage": dict(role_coverage) if role_coverage else {}, + "newest_entries": entries[:5], + "oldest_entries": entries[-5:] if len(entries) > 5 else [], + "metadata_coverage": { + "files_with_embedded_metadata": files_with_embedded_metadata, + "files_with_state_metadata": files_with_state_metadata, + "files_without_metadata": files_without_metadata, + }, + "cache_size_mb": cache_size_mb, + "indexing_complete": state_payload.get("complete", False), + "last_indexed": state_payload.get("timestamp"), + "status": "ok" + } + + +def _backfill_segment_cache_metadata(library_dir: Path, force: bool = False) -> Dict[str, Any]: + """ + Backfill metadata into existing segment cache files. + + For cache files that don't have embedded metadata, this function: + 1. Looks up the file in indexing_state.json + 2. Rewrites the cache file with metadata included + + Args: + library_dir: Path to the audio library + force: If True, rewrite all cache files even if they already have metadata + + Returns: + Dict with backfill statistics + """ + cache_dir = library_dir / ".segment_rag" + + if not cache_dir.exists(): + return { + "cache_dir": str(cache_dir), + "backfilled": 0, + "skipped": 0, + "errors": 0, + "status": "no_cache" + } + + # Load state file for metadata lookup + state_path = cache_dir / "indexing_state.json" + state_payload: Dict[str, Any] = {} + if state_path.exists(): + try: + state_payload = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + state_payload = {} + + indexed_entries = state_payload.get("indexed_entries", {}) or {} + + # Build lookup by cache_prefix + by_prefix: Dict[str, Dict[str, Any]] = {} + for entry in indexed_entries.values(): + cache_prefix = entry.get("cache_prefix") + if cache_prefix: + by_prefix[cache_prefix] = entry + + cache_files = list(cache_dir.glob("*.json.gz")) + backfilled = 0 + skipped = 0 + errors = 0 + + for cache_file in cache_files: + try: + with gzip.open(cache_file, "rt", encoding="utf-8") as handle: + payload = json.load(handle) + + # Check if already has metadata + if isinstance(payload, dict): + segments = payload.get("segments", []) or [] + metadata = payload.get("metadata", {}) or {} + if metadata and not force: + skipped += 1 + continue + elif isinstance(payload, list): + segments = payload + metadata = {} + else: + continue + + # Extract cache prefix from filename + cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem + cache_prefix = cache_stem.rsplit("__", 1)[0] + + # Look up metadata from state + state_entry = by_prefix.get(cache_prefix, {}) + file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_stem + roles = metadata.get("roles") or state_entry.get("roles") or [] + path = metadata.get("path") or state_entry.get("path") or "" + + # If we found some metadata, rewrite the cache file + if file_name or roles or path: + new_metadata = { + "file_name": file_name, + "path": path, + "roles": roles, + "indexed_at": time.time(), + "backfilled": True, + } + new_payload = { + "segments": segments, + "metadata": new_metadata + } + with gzip.open(cache_file, "wt", encoding="utf-8") as handle: + json.dump(new_payload, handle) + backfilled += 1 + logger.debug("Backfilled metadata for %s", cache_file.name) + else: + skipped += 1 + + except Exception: + errors += 1 + logger.debug("Failed to backfill %s", cache_file, exc_info=True) + + return { + "cache_dir": str(cache_dir), + "cache_files": len(cache_files), + "backfilled": backfilled, + "skipped": skipped, + "errors": errors, + "status": "ok" + } diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py new file mode 100644 index 0000000..fb1a15e --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py @@ -0,0 +1,264 @@ +""" +reference_stem_builder.py - Rebuild an Ableton arrangement directly from a reference track. +""" + +from __future__ import annotations + +import json +import logging +import socket +from pathlib import Path +from typing import Any, Dict, List, Tuple + +import soundfile as sf +import torch +from demucs.apply import apply_model +from demucs.pretrained import get_model + +try: + import librosa +except ImportError: # pragma: no cover + librosa = None + +try: + from reference_listener import ReferenceAudioListener +except ImportError: # pragma: no cover + from .reference_listener import ReferenceAudioListener + + +logger = logging.getLogger("ReferenceStemBuilder") + +HOST = "127.0.0.1" +PORT = 9877 +MESSAGE_TERMINATOR = b"\n" +SCRIPT_DIR = Path(__file__).resolve().parent +PACKAGE_DIR = SCRIPT_DIR.parent +PROJECT_SAMPLES_DIR = PACKAGE_DIR.parent / "librerias" / "organized_samples" +SAMPLES_DIR = str(PROJECT_SAMPLES_DIR) + +TRACK_LAYOUT = ( + ("REFERENCE FULL", 59, 0.72, True), + ("REF DRUMS", 10, 0.84, False), + ("REF BASS", 30, 0.82, False), + ("REF OTHER", 50, 0.68, False), + ("REF VOCALS", 40, 0.70, False), +) + +SECTION_BLUEPRINTS = { + "club": [ + ("INTRO DJ", 16), + ("GROOVE A", 16), + ("VOCAL BUILD", 8), + ("DROP A", 16), + ("BREAKDOWN", 8), + ("BUILD B", 8), + ("DROP B", 16), + ("PEAK", 8), + ("OUTRO DJ", 16), + ], + "standard": [ + ("INTRO", 8), + ("BUILD", 8), + ("DROP A", 16), + ("BREAK", 8), + ("DROP B", 16), + ("OUTRO", 8), + ], +} + + +class AbletonSocketClient: + def __init__(self, host: str = HOST, port: int = PORT): + self.host = host + self.port = port + + def send(self, command_type: str, params: Dict[str, Any] | None = None, timeout: float = 30.0) -> Dict[str, Any]: + payload = json.dumps({"type": command_type, "params": params or {}}, separators=(",", ":")).encode("utf-8") + MESSAGE_TERMINATOR + with socket.create_connection((self.host, self.port), timeout=timeout) as sock: + sock.sendall(payload) + data = b"" + while not data.endswith(MESSAGE_TERMINATOR): + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + if not data: + raise RuntimeError(f"Sin respuesta para {command_type}") + return json.loads(data.decode("utf-8", errors="replace").strip()) + + +def _resolve_reference_profile(reference_path: Path) -> Dict[str, Any]: + listener = ReferenceAudioListener(SAMPLES_DIR) + analysis = listener.analyze_reference(str(reference_path)) + structure = "club" if analysis.get("duration", 0.0) >= 180 else "standard" + return { + "tempo": float(analysis.get("tempo", 128.0) or 128.0), + "key": str(analysis.get("key", "") or ""), + "duration": float(analysis.get("duration", 0.0) or 0.0), + "structure": structure, + "listener_device": analysis.get("device", "cpu"), + } + + +def ensure_reference_wav(reference_path: Path) -> Path: + if reference_path.suffix.lower() == ".wav": + return reference_path + + if librosa is None: + raise RuntimeError("librosa no está disponible para convertir la referencia a WAV") + + wav_path = reference_path.with_suffix(".wav") + if wav_path.exists() and wav_path.stat().st_size > 0: + return wav_path + + y, sr = librosa.load(str(reference_path), sr=44100, mono=False) + if y.ndim == 1: + y = y.reshape(1, -1) + sf.write(str(wav_path), y.T, sr, subtype="PCM_16") + return wav_path + + +def separate_stems(reference_wav: Path, output_dir: Path) -> Dict[str, Path]: + output_dir.mkdir(parents=True, exist_ok=True) + stem_root = output_dir / reference_wav.stem + expected = { + "reference": reference_wav, + "drums": stem_root / "drums.wav", + "bass": stem_root / "bass.wav", + "other": stem_root / "other.wav", + "vocals": stem_root / "vocals.wav", + } + if all(path.exists() and path.stat().st_size > 0 for path in expected.values()): + return expected + + audio, sr = sf.read(str(reference_wav), always_2d=True) + if sr != 44100: + raise RuntimeError(f"Sample rate inesperado en referencia WAV: {sr}") + + model = get_model("htdemucs") + model.cpu() + model.eval() + waveform = torch.tensor(audio.T, dtype=torch.float32) + separated = apply_model(model, waveform[None], device="cpu", progress=False)[0] + + stem_root.mkdir(parents=True, exist_ok=True) + for stem_name, tensor in zip(model.sources, separated): + stem_path = stem_root / f"{stem_name}.wav" + sf.write(str(stem_path), tensor.detach().cpu().numpy().T, sr, subtype="PCM_16") + + return expected + + +def _sections_for_structure(structure: str) -> List[Tuple[str, int]]: + return list(SECTION_BLUEPRINTS.get(structure.lower(), SECTION_BLUEPRINTS["standard"])) + + +def _create_track(client: AbletonSocketClient, name: str, color: int, volume: float) -> int: + response = client.send("create_track", {"type": "audio", "index": -1}) + if response.get("status") != "success": + raise RuntimeError(response.get("message", f"No se pudo crear {name}")) + track_index = int(response.get("result", {}).get("index")) + client.send("set_track_name", {"index": track_index, "name": name}) + client.send("set_track_color", {"index": track_index, "color": color}) + client.send("set_track_volume", {"index": track_index, "volume": volume}) + return track_index + + +def _import_full_length_audio(client: AbletonSocketClient, track_index: int, file_path: Path, name: str) -> None: + response = client.send("create_arrangement_audio_pattern", { + "track_index": track_index, + "file_path": str(file_path), + "positions": [0.0], + "name": name, + }, timeout=120.0) + if response.get("status") != "success": + raise RuntimeError(response.get("message", f"No se pudo importar {name}")) + + +def _prepare_navigation_scenes(client: AbletonSocketClient, structure: str) -> None: + sections = _sections_for_structure(structure) + session_info = client.send("get_session_info") + if session_info.get("status") != "success": + return + + scene_count = int(session_info.get("result", {}).get("num_scenes", 0) or 0) + target_count = len(sections) + + while scene_count < target_count: + create_response = client.send("create_scene", {"index": -1}) + if create_response.get("status") != "success": + break + scene_count += 1 + + while scene_count > target_count and scene_count > 1: + delete_response = client.send("delete_scene", {"index": scene_count - 1}) + if delete_response.get("status") != "success": + break + scene_count -= 1 + + for scene_index, (section_name, _) in enumerate(sections): + client.send("set_scene_name", {"index": scene_index, "name": section_name}) + + +def rebuild_project_from_reference(reference_path: Path) -> Dict[str, Any]: + reference_path = reference_path.resolve() + if not reference_path.exists(): + raise FileNotFoundError(reference_path) + + profile = _resolve_reference_profile(reference_path) + reference_wav = ensure_reference_wav(reference_path) + stems = separate_stems(reference_wav, reference_path.parent / "stems") + + client = AbletonSocketClient() + clear_response = client.send("clear_project", {"keep_tracks": 0}, timeout=120.0) + if clear_response.get("status") != "success": + raise RuntimeError(clear_response.get("message", "No se pudo limpiar el proyecto")) + + client.send("stop", {}) + client.send("set_tempo", {"tempo": round(profile["tempo"], 3)}) + client.send("show_arrangement_view", {}) + client.send("jump_to", {"time": 0}) + + created = [] + for (track_name, color, volume, muted), stem_key in zip(TRACK_LAYOUT, ("reference", "drums", "bass", "other", "vocals")): + track_index = _create_track(client, track_name, color, volume) + _import_full_length_audio(client, track_index, stems[stem_key], track_name) + if muted: + client.send("set_track_mute", {"index": track_index, "mute": True}) + created.append({ + "track_index": track_index, + "name": track_name, + "file_path": str(stems[stem_key]), + }) + + _prepare_navigation_scenes(client, profile["structure"]) + client.send("loop_selection", {"start": 0, "length": max(32.0, round(profile["duration"] * profile["tempo"] / 60.0, 3)), "enable": False}) + client.send("jump_to", {"time": 0}) + client.send("show_arrangement_view", {}) + + session_info = client.send("get_session_info") + return { + "reference": str(reference_path), + "tempo": profile["tempo"], + "key": profile["key"], + "structure": profile["structure"], + "listener_device": profile["listener_device"], + "stems": created, + "session_info": session_info.get("result", {}), + } + + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser(description="Rebuild an Ableton project directly from a reference track.") + parser.add_argument("reference_path", help="Absolute or relative path to the reference audio file") + args = parser.parse_args() + + result = rebuild_project_from_reference(Path(args.reference_path)) + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt new file mode 100644 index 0000000..cf2a8b2 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt @@ -0,0 +1,13 @@ +# Dependencias de AbletonMCP-AI Server +# Instalar con: pip install -r requirements.txt + +mcp>=1.0.0 +# Servidor MCP FastMCP + +# Opcional: para análisis de audio avanzado +# numpy>=1.24.0 +# librosa>=0.10.0 + +# Opcional: para procesamiento con GPU AMD +# torch==2.4.1 +# torch-directml>=0.2.5 diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py new file mode 100644 index 0000000..5224785 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py @@ -0,0 +1,525 @@ +""" +retrieval_benchmark.py - Offline benchmark harness for retrieval quality inspection. + +Analyzes reference tracks and outputs top-N candidates per role to help spot +role contamination and evaluate retrieval quality. + +Usage: + python retrieval_benchmark.py --reference "path/to/track.mp3" + python retrieval_benchmark.py --reference "track1.mp3" "track2.mp3" --top-n 10 + python retrieval_benchmark.py --reference "track.mp3" --output results.json --format json + python retrieval_benchmark.py --reference "track.mp3" --output results.md --format markdown +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +import time +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Add parent directory to path for imports when running as script +sys.path.insert(0, str(Path(__file__).parent)) + +from reference_listener import ReferenceAudioListener, ROLE_SEGMENT_SETTINGS + +logger = logging.getLogger(__name__) + + +def _default_library_dir() -> Path: + """Get the default library directory.""" + return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" + + +def run_benchmark( + reference_paths: List[str], + library_dir: Path, + top_n: int = 10, + roles: Optional[List[str]] = None, + duration_limit: Optional[float] = None, +) -> Dict[str, Any]: + """ + Run retrieval benchmark on one or more reference tracks. + + Args: + reference_paths: List of paths to reference audio files + library_dir: Path to the sample library + top_n: Number of top candidates to show per role + roles: Optional list of specific roles to analyze + duration_limit: Optional duration limit for analysis + + Returns: + Dict containing benchmark results for each reference + """ + listener = ReferenceAudioListener(str(library_dir)) + + all_roles = list(ROLE_SEGMENT_SETTINGS.keys()) + target_roles = [r for r in (roles or all_roles) if r in all_roles] + + results = { + "benchmark_info": { + "library_dir": str(library_dir), + "top_n": top_n, + "roles": target_roles, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "device": listener.device_name, + }, + "references": [], + } + + for ref_path in reference_paths: + ref_path = Path(ref_path) + if not ref_path.exists(): + logger.warning("Reference file not found: %s", ref_path) + continue + + logger.info("Analyzing reference: %s", ref_path.name) + + try: + start_time = time.time() + + # Run match_assets to get candidates per role + match_result = listener.match_assets(str(ref_path)) + reference_info = match_result.get("reference", {}) + matches = match_result.get("matches", {}) + + elapsed = time.time() - start_time + + ref_result = { + "file_name": ref_path.name, + "path": str(ref_path), + "analysis_time_seconds": round(elapsed, 2), + "reference_info": { + "tempo": reference_info.get("tempo"), + "key": reference_info.get("key"), + "duration": reference_info.get("duration"), + "rms_mean": reference_info.get("rms_mean"), + "onset_mean": reference_info.get("onset_mean"), + "spectral_centroid": reference_info.get("spectral_centroid"), + }, + "sections": [ + { + "kind": s.get("kind"), + "start": s.get("start"), + "end": s.get("end"), + "bars": s.get("bars"), + } + for s in match_result.get("reference_sections", []) + ], + "role_candidates": {}, + } + + # Process each role + for role in target_roles: + role_matches = matches.get(role, []) + top_candidates = role_matches[:top_n] + + ref_result["role_candidates"][role] = { + "total_available": len(role_matches), + "top_candidates": [ + { + "rank": i + 1, + "file_name": c.get("file_name"), + "path": c.get("path"), + "score": c.get("score"), + "cosine": c.get("cosine"), + "segment_score": c.get("segment_score"), + "catalog_score": c.get("catalog_score"), + "tempo": c.get("tempo"), + "key": c.get("key"), + "duration": c.get("duration"), + } + for i, c in enumerate(top_candidates) + ], + } + + results["references"].append(ref_result) + logger.info("Completed analysis in %.2fs", elapsed) + + except Exception as e: + logger.error("Failed to analyze %s: %s", ref_path, e, exc_info=True) + results["references"].append({ + "file_name": ref_path.name, + "path": str(ref_path), + "error": str(e), + }) + + return results + + +def analyze_role_contamination(results: Dict[str, Any]) -> Dict[str, Any]: + """ + Analyze results for potential role contamination issues. + + Returns a dict with contamination analysis: + - files appearing in multiple roles + - misnamed files (e.g., "bass" appearing in "kick" role) + - score distribution anomalies + """ + contamination = { + "cross_role_files": [], + "potential_mismatches": [], + "role_score_stats": {}, + } + + # Track files appearing in multiple roles + file_to_roles: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + + for ref in results.get("references", []): + ref_name = ref.get("file_name", "unknown") + + for role, role_data in ref.get("role_candidates", {}).items(): + for candidate in role_data.get("top_candidates", []): + file_name = candidate.get("file_name", "") + if file_name: + file_to_roles[file_name].append({ + "reference": ref_name, + "role": role, + "rank": candidate.get("rank"), + "score": candidate.get("score"), + }) + + # Find files appearing in multiple roles + for file_name, appearances in file_to_roles.items(): + unique_roles = set(a["role"] for a in appearances) + if len(unique_roles) > 1: + contamination["cross_role_files"].append({ + "file_name": file_name, + "roles": list(unique_roles), + "appearances": appearances, + }) + + # Check for potential mismatches (filename suggests different role) + role_keywords = { + "kick": ["kick"], + "snare": ["snare", "clap"], + "hat": ["hat", "hihat", "hi-hat"], + "bass_loop": ["bass", "sub", "808"], + "perc_loop": ["perc", "percussion", "conga", "bongo"], + "top_loop": ["top", "drum loop", "full drum"], + "synth_loop": ["synth", "lead", "pad", "chord", "arp"], + "vocal_loop": ["vocal", "vox", "acapella"], + "crash_fx": ["crash", "cymbal", "impact"], + "fill_fx": ["fill", "transition", "tom"], + "snare_roll": ["roll", "snareroll"], + "atmos_fx": ["atmos", "drone", "ambient", "texture"], + "vocal_shot": ["shot", "vocal shot", "chop"], + } + + for ref in results.get("references", []): + for role, role_data in ref.get("role_candidates", {}).items(): + for candidate in role_data.get("top_candidates", []): + file_name = candidate.get("file_name", "").lower() + if not file_name: + continue + + # Check if file name suggests a different role + expected_keywords = role_keywords.get(role, []) + other_role_matches = [] + + for other_role, keywords in role_keywords.items(): + if other_role == role: + continue + if any(kw in file_name for kw in keywords): + other_role_matches.append(other_role) + + if other_role_matches and expected_keywords: + # File name matches another role but not this one + if not any(kw in file_name for kw in expected_keywords): + contamination["potential_mismatches"].append({ + "file_name": candidate.get("file_name"), + "assigned_role": role, + "rank": candidate.get("rank"), + "score": candidate.get("score"), + "suggested_roles": other_role_matches, + }) + + # Calculate score distribution per role + for ref in results.get("references", []): + for role, role_data in ref.get("role_candidates", {}).items(): + scores = [ + c.get("score", 0) + for c in role_data.get("top_candidates", []) + if c.get("score") is not None + ] + + if scores: + contamination["role_score_stats"][role] = { + "min": round(min(scores), 4), + "max": round(max(scores), 4), + "avg": round(sum(scores) / len(scores), 4), + "count": len(scores), + } + + return contamination + + +def format_output_json(results: Dict[str, Any]) -> str: + """Format results as JSON string.""" + return json.dumps(results, indent=2, ensure_ascii=False) + + +def format_output_markdown(results: Dict[str, Any]) -> str: + """Format results as markdown string.""" + lines = [] + + # Header + lines.append("# Retrieval Benchmark Report") + lines.append("") + lines.append(f"**Generated:** {results['benchmark_info']['timestamp']}") + lines.append(f"**Library:** `{results['benchmark_info']['library_dir']}`") + lines.append(f"**Top N:** {results['benchmark_info']['top_n']}") + lines.append(f"**Device:** {results['benchmark_info']['device']}") + lines.append("") + + # Process each reference + for ref in results.get("references", []): + lines.append(f"## Reference: {ref.get('file_name', 'unknown')}") + lines.append("") + + # Error case + if "error" in ref: + lines.append(f"**Error:** {ref['error']}") + lines.append("") + continue + + # Reference info + ref_info = ref.get("reference_info", {}) + lines.append("### Reference Analysis") + lines.append("") + lines.append("| Property | Value |") + lines.append("|----------|-------|") + lines.append(f"| Tempo | {ref_info.get('tempo', 'N/A')} BPM |") + lines.append(f"| Key | {ref_info.get('key', 'N/A')} |") + lines.append(f"| Duration | {ref_info.get('duration', 'N/A')}s |") + lines.append(f"| RMS Mean | {ref_info.get('rms_mean', 'N/A')} |") + lines.append(f"| Onset Mean | {ref_info.get('onset_mean', 'N/A')} |") + lines.append(f"| Spectral Centroid | {ref_info.get('spectral_centroid', 'N/A')} Hz |") + lines.append("") + + # Sections + sections = ref.get("sections", []) + if sections: + lines.append("### Detected Sections") + lines.append("") + lines.append("| Type | Start | End | Bars |") + lines.append("|------|-------|-----|------|") + for s in sections: + lines.append(f"| {s.get('kind', 'N/A')} | {s.get('start', 'N/A')}s | {s.get('end', 'N/A')}s | {s.get('bars', 'N/A')} |") + lines.append("") + + # Role candidates + lines.append("### Top Candidates per Role") + lines.append("") + + for role, role_data in ref.get("role_candidates", {}).items(): + total = role_data.get("total_available", 0) + lines.append(f"#### {role} ({total} available)") + lines.append("") + + candidates = role_data.get("top_candidates", []) + if not candidates: + lines.append("*No candidates found*") + lines.append("") + continue + + lines.append("| Rank | File | Score | Cosine | Seg | Catalog | Tempo | Key | Duration |") + lines.append("|------|------|-------|--------|-----|---------|-------|-----|----------|") + + for c in candidates: + lines.append( + f"| {c.get('rank', 'N/A')} | " + f"`{c.get('file_name', 'N/A')[:40]}` | " + f"{c.get('score', 0):.4f} | " + f"{c.get('cosine', 0):.4f} | " + f"{c.get('segment_score', 0):.4f} | " + f"{c.get('catalog_score', 0):.4f} | " + f"{c.get('tempo', 'N/A')} | " + f"{c.get('key', 'N/A')} | " + f"{c.get('duration', 'N/A'):.2f}s |" + ) + lines.append("") + + # Contamination analysis + if "contamination_analysis" in results: + contam = results["contamination_analysis"] + lines.append("## Role Contamination Analysis") + lines.append("") + + # Cross-role files + cross_role = contam.get("cross_role_files", []) + if cross_role: + lines.append("### Files Appearing in Multiple Roles") + lines.append("") + for item in cross_role: + lines.append(f"- **{item['file_name']}**") + lines.append(f" - Roles: {', '.join(item['roles'])}") + for app in item["appearances"]: + lines.append(f" - {app['role']}: rank {app['rank']}, score {app['score']:.4f}") + lines.append("") + + # Potential mismatches + mismatches = contam.get("potential_mismatches", []) + if mismatches: + lines.append("### Potential Role Mismatches") + lines.append("") + lines.append("Files whose names suggest a different role than assigned:") + lines.append("") + for item in mismatches: + lines.append(f"- **{item['file_name']}**") + lines.append(f" - Assigned: {item['assigned_role']} (rank {item['rank']}, score {item['score']:.4f})") + lines.append(f" - Suggested: {', '.join(item['suggested_roles'])}") + lines.append("") + + # Score stats + score_stats = contam.get("role_score_stats", {}) + if score_stats: + lines.append("### Score Distribution per Role") + lines.append("") + lines.append("| Role | Min | Max | Avg | Count |") + lines.append("|------|-----|-----|-----|-------|") + for role, stats in sorted(score_stats.items()): + lines.append( + f"| {role} | {stats['min']:.4f} | {stats['max']:.4f} | " + f"{stats['avg']:.4f} | {stats['count']} |" + ) + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Offline benchmark harness for retrieval quality inspection.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --reference "track.mp3" + %(prog)s --reference "track1.mp3" "track2.mp3" --top-n 15 + %(prog)s --reference "track.mp3" --output results.md --format markdown + %(prog)s --reference "track.mp3" --roles kick snare hat --top-n 20 + """, + ) + + parser.add_argument( + "--reference", "-r", + nargs="+", + required=True, + help="One or more reference audio files to analyze", + ) + parser.add_argument( + "--library-dir", + default=str(_default_library_dir()), + help="Audio library directory (default: ../librerias/all_tracks)", + ) + parser.add_argument( + "--top-n", "-n", + type=int, + default=10, + help="Number of top candidates to show per role (default: 10)", + ) + parser.add_argument( + "--roles", + nargs="*", + default=None, + help="Specific roles to analyze (default: all roles)", + ) + parser.add_argument( + "--output", "-o", + type=str, + default=None, + help="Output file path for results", + ) + parser.add_argument( + "--format", "-f", + choices=["json", "markdown", "md"], + default=None, + help="Output format (json or markdown). Auto-detected from output file extension if not specified.", + ) + parser.add_argument( + "--analyze-contamination", + action="store_true", + help="Include role contamination analysis in output", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging", + ) + parser.add_argument( + "--duration-limit", + type=float, + default=None, + help="Optional duration limit for audio analysis", + ) + + args = parser.parse_args() + + # Configure logging + if args.verbose: + logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + else: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + # Validate reference files + reference_paths = [] + for ref in args.reference: + ref_path = Path(ref) + if ref_path.exists(): + reference_paths.append(str(ref_path)) + else: + logger.warning("Reference file not found: %s", ref) + + if not reference_paths: + logger.error("No valid reference files provided") + return 1 + + # Run benchmark + logger.info("Running retrieval benchmark on %d reference(s)", len(reference_paths)) + + results = run_benchmark( + reference_paths=reference_paths, + library_dir=Path(args.library_dir), + top_n=args.top_n, + roles=args.roles, + duration_limit=args.duration_limit, + ) + + # Add contamination analysis if requested + if args.analyze_contamination: + logger.info("Analyzing role contamination...") + results["contamination_analysis"] = analyze_role_contamination(results) + + # Determine output format + output_format = args.format + if output_format is None and args.output: + output_format = "markdown" if args.output.endswith(".md") else "json" + output_format = output_format or "text" + + # Format output + if output_format in ("markdown", "md"): + output_text = format_output_markdown(results) + elif output_format == "json": + output_text = format_output_json(results) + else: + # Plain text summary + output_text = format_output_markdown(results) + + # Write to file or stdout + if args.output: + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_text, encoding="utf-8") + logger.info("Results written to: %s", output_path) + else: + print(output_text) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py new file mode 100644 index 0000000..a2a79a8 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py @@ -0,0 +1,469 @@ +""" +role_matcher.py - Phase 4: Role validation and sample matching utilities + +This module provides enhanced role matching for sample selection with: +- Role validation based on audio characteristics +- Aggressive sample detection and filtering +- Logging of matching decisions +- Integration with reference_listener and sample_selector +""" + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("RoleMatcher") + + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +# Valid roles for sample matching with their expected characteristics +VALID_ROLES = { + # One-shot drums + "kick": {"max_duration": 2.0, "min_onset": 0.3, "is_loop": False, "bus": "drums"}, + "snare": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, + "hat": {"max_duration": 1.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, + "clap": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, + "ride": {"max_duration": 3.0, "min_onset": 0.15, "is_loop": False, "bus": "drums"}, + "perc": {"max_duration": 2.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, + # Loops + "bass_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "bass"}, + "perc_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, + "top_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, + "synth_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "music"}, + "vocal_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "vocal"}, + # FX + "crash_fx": {"max_duration": 4.0, "is_loop": False, "bus": "fx"}, + "fill_fx": {"max_duration": 8.0, "is_loop": False, "bus": "fx"}, + "snare_roll": {"max_duration": 8.0, "is_loop": False, "bus": "drums"}, + "atmos_fx": {"min_duration": 4.0, "is_loop": True, "bus": "fx"}, + "vocal_shot": {"max_duration": 3.0, "is_loop": False, "bus": "vocal"}, + # Resample layers + "resample_reverse": {"is_loop": False, "bus": "fx"}, + "resample_riser": {"is_loop": False, "bus": "fx"}, + "resample_downlifter": {"is_loop": False, "bus": "fx"}, + "resample_stutter": {"is_loop": False, "bus": "vocal"}, +} + +# Keywords that indicate aggressive/hard samples that may be misclassified +AGGRESSIVE_KEYWORDS = { + # Very aggressive kick patterns + "hard", "distorted", "industrial", "slam", "punch", "brutal", + # Potentially misclassified + "subdrop", "impact", "explosion", "destroy", +} + +# Keywords that are acceptable for aggressive genres +GENRE_APPROPRIATE_AGGRESSIVE = { + "industrial-techno", "hard-techno", "raw-techno", "psytrance", "dark-techno" +} + +# Role aliases for flexible matching +ROLE_ALIASES = { + "kick": ["kick", "bd", "bassdrum", "bass_drum"], + "snare": ["snare", "sd", "snr"], + "clap": ["clap", "cp", "handclap"], + "hat": ["hat", "hihat", "hi_hat", "hhat", "closed_hat", "hat_closed"], + "hat_open": ["open_hat", "hat_open", "ohat", "openhihat"], + "ride": ["ride", "rd", "cymbal"], + "perc": ["perc", "percussion", "percs"], + "bass_loop": ["bass_loop", "bassloop", "bass loop", "sub_bass"], + "perc_loop": ["perc_loop", "percloop", "percussion loop", "perc loop"], + "top_loop": ["top_loop", "toploop", "top loop", "full_drum"], + "synth_loop": ["synth_loop", "synthloop", "synth loop", "chord_loop", "stab"], + "vocal_loop": ["vocal_loop", "vocalloop", "vocal loop", "vox_loop", "vox"], + "crash_fx": ["crash", "crash_fx", "crashfx", "impact_fx"], + "fill_fx": ["fill", "fill_fx", "fillfx", "tom_fill", "transition"], + "snare_roll": ["snare_roll", "snareroll", "snare roll", "snr_roll"], + "atmos_fx": ["atmos", "atmos_fx", "atmosfx", "drone", "pad_fx"], + "vocal_shot": ["vocal_shot", "vocalshot", "vocal shot", "vocal_one_shot"], +} + +# Minimum score thresholds for role matching +ROLE_SCORE_THRESHOLDS = { + "kick": 0.35, + "snare": 0.32, + "hat": 0.30, + "clap": 0.32, + "bass_loop": 0.38, + "perc_loop": 0.35, + "top_loop": 0.35, + "synth_loop": 0.36, + "vocal_loop": 0.38, + "crash_fx": 0.30, + "fill_fx": 0.32, + "snare_roll": 0.30, + "atmos_fx": 0.32, + "vocal_shot": 0.34, +} + + +# ============================================================================ +# VALIDATION FUNCTIONS +# ============================================================================ + +def validate_role_for_sample( + role: str, + sample_data: Dict[str, Any], + genre: Optional[str] = None, +) -> Dict[str, Any]: + """ + Validates if a sample is appropriate for a given role. + + Args: + role: The role to validate for (e.g., 'kick', 'bass_loop') + sample_data: Sample metadata with keys like 'duration', 'onset_mean', 'file_name', 'rms_mean' + genre: Optional genre for context-aware aggressive sample handling + + Returns: + Dict with keys: + - 'valid' (bool): Whether the sample passes validation + - 'score' (float): Raw validation score (0.0-1.0) + - 'warnings' (list): List of warning messages + - 'adjusted_score' (float): Score after penalties + """ + if role not in VALID_ROLES: + return {"valid": True, "score": 0.5, "warnings": [f"Unknown role: {role}"], "adjusted_score": 0.5} + + role_config = VALID_ROLES[role] + warnings: List[str] = [] + score = 1.0 + + duration = float(sample_data.get("duration", 0.0) or 0.0) + onset = float(sample_data.get("onset_mean", 0.0) or 0.0) + file_name = str(sample_data.get("file_name", "") or "").lower() + rms = float(sample_data.get("rms_mean", 0.0) or 0.0) + + # Duration validation + if role_config.get("is_loop"): + min_dur = role_config.get("min_duration", 2.0) + max_dur = role_config.get("max_duration", 16.0) + if duration < min_dur: + warnings.append(f"Duration {duration:.1f}s too short for loop role (min {min_dur}s)") + score *= 0.7 + elif max_dur and duration > max_dur: + warnings.append(f"Duration {duration:.1f}s too long for role (max {max_dur}s)") + score *= 0.85 + else: + max_dur = role_config.get("max_duration", 3.0) + if duration > max_dur: + warnings.append(f"Duration {duration:.1f}s too long for one-shot role (max {max_dur}s)") + score *= 0.75 + if "loop" in file_name and role in ["kick", "snare", "hat", "clap"]: + warnings.append("One-shot role has 'loop' in filename") + score *= 0.65 + + # Onset validation for percussive elements + min_onset = role_config.get("min_onset", 0.0) + if min_onset > 0 and onset < min_onset: + warnings.append(f"Onset {onset:.2f} below minimum {min_onset:.2f}") + score *= 0.85 + + # Check for aggressive samples that might be misclassified + aggressive_penalty = 1.0 + is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE + + for keyword in AGGRESSIVE_KEYWORDS: + if keyword in file_name: + if not is_aggressive_genre: + aggressive_penalty *= 0.88 + warnings.append(f"Aggressive keyword '{keyword}' found for non-aggressive genre") + + score *= aggressive_penalty + + # RMS validation for certain roles + if role in ["kick", "snare", "clap"] and rms > 0.4: + warnings.append(f"High RMS {rms:.3f} for one-shot role") + score *= 0.9 + + adjusted_score = max(0.1, min(1.0, score)) + + return { + "valid": score >= 0.4, + "score": score, + "warnings": warnings, + "adjusted_score": adjusted_score, + } + + +def resolve_role_from_alias(alias: str) -> Optional[str]: + """ + Resolves a role name from various aliases. + + Args: + alias: A potential role alias (e.g., 'bd', 'hihat', 'bass loop') + + Returns: + The canonical role name or None if not found + """ + alias_lower = alias.lower().strip().replace("-", "_").replace(" ", "_") + + # Direct match + if alias_lower in VALID_ROLES: + return alias_lower + + # Check aliases + for role, aliases in ROLE_ALIASES.items(): + normalized_aliases = [a.lower().replace("-", "_").replace(" ", "_") for a in aliases] + if alias_lower in normalized_aliases: + return role + + return None + + +def get_bus_for_role(role: str) -> str: + """ + Gets the appropriate bus for a role. + + Args: + role: The role name + + Returns: + Bus name ('drums', 'bass', 'music', 'vocal', or 'fx') + """ + if role in VALID_ROLES: + return VALID_ROLES[role].get("bus", "music") + return "music" + + +# ============================================================================ +# LOGGING FUNCTIONS +# ============================================================================ + +def log_matching_decision( + role: str, + selected_sample: Optional[Dict[str, Any]], + candidates_count: int, + final_score: float, + validation_result: Optional[Dict[str, Any]] = None, +) -> None: + """ + Logs detailed matching decisions for debugging and analysis. + + Args: + role: The role being matched + selected_sample: The selected sample dict or None + candidates_count: Number of candidates considered + final_score: The final matching score + validation_result: Optional validation result dict + """ + if not selected_sample: + logger.info( + f"[MATCH] Role '{role}': No sample selected (0/{candidates_count} candidates)" + ) + return + + sample_name = selected_sample.get("file_name", "unknown") + sample_tempo = selected_sample.get("tempo", 0.0) + sample_key = selected_sample.get("key", "N/A") + sample_dur = selected_sample.get("duration", 0.0) + + log_parts = [ + f"[MATCH] Role '{role}':", + f"Sample: {sample_name}", + f"Score: {final_score:.3f}", + f"Tempo: {sample_tempo:.1f}", + f"Key: {sample_key}", + f"Duration: {sample_dur:.1f}s", + f"Candidates: {candidates_count}", + ] + + if validation_result: + warnings = validation_result.get("warnings", []) + if warnings: + log_parts.append(f"Warnings: {', '.join(warnings)}") + log_parts.append(f"Validated: {validation_result.get('valid', True)}") + + logger.info(" | ".join(log_parts)) + + +# ============================================================================ +# ENHANCEMENT FUNCTIONS +# ============================================================================ + +def enhance_sample_matching( + matches: Dict[str, List[Dict[str, Any]]], + reference: Dict[str, Any], + genre: Optional[str] = None, +) -> Dict[str, List[Dict[str, Any]]]: + """ + Enhances sample matching results with validation and filtering. + + This function takes raw matches from reference_listener and applies: + 1. Role validation based on audio characteristics + 2. Aggressive sample filtering + 3. Score adjustment based on validation results + + Args: + matches: Raw matches from reference_listener (role -> list of sample dicts) + reference: Reference track analysis data + genre: Target genre for context-aware filtering + + Returns: + Enhanced matches with validation scores and filtering applied + """ + enhanced: Dict[str, List[Dict[str, Any]]] = {} + + for role, candidates in matches.items(): + if not candidates: + enhanced[role] = [] + continue + + threshold = ROLE_SCORE_THRESHOLDS.get(role, 0.30) + enhanced_candidates: List[Dict[str, Any]] = [] + + for candidate in candidates: + # Create a copy to avoid modifying the original + enhanced_candidate = dict(candidate) + + # Validate the sample for this role + validation = validate_role_for_sample(role, candidate, genre) + enhanced_candidate["validation"] = validation + + # Apply validation penalty to the score + original_score = float(candidate.get("score", 0.0)) + adjusted_score = original_score * validation["adjusted_score"] + enhanced_candidate["adjusted_score"] = round(adjusted_score, 6) + + # Filter out samples below threshold + if adjusted_score >= threshold: + enhanced_candidates.append(enhanced_candidate) + else: + logger.debug( + f"[FILTER] Role '{role}': Filtered out '{candidate.get('file_name', 'unknown')}' " + f"(score {adjusted_score:.3f} < threshold {threshold})" + ) + + # Re-sort by adjusted score + enhanced_candidates.sort(key=lambda x: float(x.get("adjusted_score", 0.0)), reverse=True) + enhanced[role] = enhanced_candidates + + # Log summary + filtered_count = len(candidates) - len(enhanced_candidates) + if filtered_count > 0: + logger.info( + f"[ENHANCE] Role '{role}': {len(enhanced_candidates)}/{len(candidates)} candidates passed validation " + f"({filtered_count} filtered out)" + ) + + return enhanced + + +def filter_aggressive_samples( + candidates: List[Dict[str, Any]], + genre: Optional[str] = None, + strict: bool = False, +) -> List[Dict[str, Any]]: + """ + Filters out samples with aggressive keywords unless appropriate for the genre. + + Args: + candidates: List of sample candidate dicts + genre: Target genre + strict: If True, apply stricter filtering + + Returns: + Filtered list of candidates + """ + is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE + + if is_aggressive_genre: + # For aggressive genres, don't filter aggressive samples + return candidates + + filtered = [] + for candidate in candidates: + file_name = str(candidate.get("file_name", "") or "").lower() + aggressive_count = sum(1 for kw in AGGRESSIVE_KEYWORDS if kw in file_name) + + if strict and aggressive_count > 0: + continue + + # Apply penalty instead of filtering completely + if aggressive_count > 0: + penalty = 0.85 ** aggressive_count + candidate_copy = dict(candidate) + original_score = float(candidate.get("score", 0.0)) + candidate_copy["score"] = original_score * penalty + filtered.append(candidate_copy) + else: + filtered.append(candidate) + + return filtered + + +# ============================================================================ +# INTEGRATION HELPERS +# ============================================================================ + +def create_enhanced_match_report( + role: str, + selected_sample: Optional[Dict[str, Any]], + all_candidates: List[Dict[str, Any]], + validation_result: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Creates a detailed report for a matching decision. + + Args: + role: The role being matched + selected_sample: The selected sample + all_candidates: All candidates that were considered + validation_result: Validation result for the selected sample + + Returns: + A dict with detailed matching report + """ + report = { + "role": role, + "selected": selected_sample is not None, + "candidates_count": len(all_candidates), + "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), + } + + if selected_sample: + report["selected_sample"] = { + "name": selected_sample.get("file_name"), + "path": selected_sample.get("path"), + "score": selected_sample.get("score"), + "adjusted_score": selected_sample.get("adjusted_score"), + "tempo": selected_sample.get("tempo"), + "key": selected_sample.get("key"), + "duration": selected_sample.get("duration"), + } + + if validation_result: + report["validation"] = { + "valid": validation_result.get("valid"), + "score": validation_result.get("score"), + "warnings": validation_result.get("warnings", []), + } + + return report + + +def get_role_info(role: str) -> Dict[str, Any]: + """ + Gets comprehensive information about a role. + + Args: + role: The role name + + Returns: + Dict with role information including valid samples count, thresholds, etc. + """ + if role not in VALID_ROLES: + return {"error": f"Unknown role: {role}"} + + config = VALID_ROLES[role] + aliases = ROLE_ALIASES.get(role, []) + + return { + "role": role, + "config": config, + "aliases": aliases, + "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), + "bus": config.get("bus", "music"), + "is_loop": config.get("is_loop", False), + } \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py new file mode 100644 index 0000000..186b338 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py @@ -0,0 +1,308 @@ +""" +sample_index.py - Índice y búsqueda de samples para AbletonMCP-AI + +Gestiona la librería de samples locales con metadatos extraídos de los nombres. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +import re + +logger = logging.getLogger("SampleIndex") + + +class SampleIndex: + """Índice de samples con búsqueda y metadatos""" + + # Categorías por palabras clave + CATEGORIES = { + 'kick': ['kick', 'bd', 'bass drum', 'kick drum'], + 'snare': ['snare', 'sd', 'snr'], + 'clap': ['clap', 'clp'], + 'hat': ['hat', 'hh', 'hihat', 'hi-hat', 'closed hat', 'open hat'], + 'perc': ['perc', 'percussion', 'conga', 'bongo', 'shaker', 'tamb', 'timb'], + 'bass': ['bass', 'bassline', 'sub', '808', ' Reese'], + 'synth': ['synth', 'lead', 'pad', 'arp', 'pluck', 'stab', 'chord'], + 'vocal': ['vocal', 'vox', 'voice', 'speech', 'talk'], + 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit'], + 'loop': ['loop', 'full', 'groove'], + } + + def __init__(self, base_dir: str): + """ + Inicializa el índice de samples + + Args: + base_dir: Directorio base donde buscar samples + """ + self.base_dir = Path(base_dir) + self.samples: List[Dict[str, Any]] = [] + self.index_file = self.base_dir / ".sample_index.json" + + # Cargar o construir índice + if self.index_file.exists(): + self._load_index() + else: + self._build_index() + self._save_index() + + def _build_index(self): + """Construye el índice escaneando el directorio""" + logger.info(f"Construyendo índice de samples en: {self.base_dir}") + + extensions = {'.wav', '.aif', '.aiff', '.mp3', '.ogg'} + + for file_path in self.base_dir.rglob('*'): + if file_path.suffix.lower() in extensions: + sample_info = self._analyze_sample(file_path) + self.samples.append(sample_info) + + logger.info(f"Índice construido: {len(self.samples)} samples encontrados") + + def _analyze_sample(self, file_path: Path) -> Dict[str, Any]: + """Analiza un sample y extrae metadatos del nombre""" + name = file_path.stem + name_lower = name.lower() + + # Determinar categoría + category = self._detect_category(name_lower) + + # Extraer key del nombre + key = self._extract_key(name) + + # Extraer BPM del nombre + bpm = self._extract_bpm(name) + + return { + 'name': name, + 'path': str(file_path), + 'category': category, + 'key': key, + 'bpm': bpm, + 'size': file_path.stat().st_size if file_path.exists() else 0, + } + + def _detect_category(self, name: str) -> str: + """Detecta la categoría basada en palabras clave""" + for category, keywords in self.CATEGORIES.items(): + for keyword in keywords: + if keyword in name: + return category + return 'unknown' + + def _extract_key(self, name: str) -> Optional[str]: + """Extrae la tonalidad del nombre del archivo""" + # Patrones comunes: "Key A", "in A", "A minor", "Am", "F#m", etc. + patterns = [ + r'[_\s\-]([A-G][#b]?m?)\s*(?:minor|major)?[_\s\-]?', + r'[_\s\-]([A-G][#b]?)[_\s\-]', + r'\bin\s+([A-G][#b]?m?)\b', + r'Key\s+([A-G][#b]?m?)', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + return key + + return None + + def _extract_bpm(self, name: str) -> Optional[int]: + """Extrae el BPM del nombre del archivo""" + # Patrones: "128 BPM", "_128_", "128bpm", etc. + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: # Rango razonable + return bpm + + return None + + def _load_index(self): + """Carga el índice desde archivo""" + try: + with open(self.index_file, 'r') as f: + data = json.load(f) + self.samples = data.get('samples', []) + logger.info(f"Índice cargado: {len(self.samples)} samples") + except Exception as e: + logger.error(f"Error cargando índice: {e}") + self._build_index() + + def _save_index(self): + """Guarda el índice a archivo""" + try: + with open(self.index_file, 'w') as f: + json.dump({ + 'samples': self.samples, + 'base_dir': str(self.base_dir) + }, f, indent=2) + logger.info(f"Índice guardado en: {self.index_file}") + except Exception as e: + logger.error(f"Error guardando índice: {e}") + + def search(self, query: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: + """ + Busca samples por query y/o categoría + + Args: + query: Término de búsqueda + category: Categoría específica (opcional) + limit: Número máximo de resultados + + Returns: + Lista de samples que coinciden + """ + query_lower = query.lower() + results = [] + + for sample in self.samples: + # Filtrar por categoría si se especificó + if category and sample['category'] != category.lower(): + continue + + # Buscar en nombre + name = sample['name'].lower() + if query_lower in name: + # Calcular score de relevancia + score = 0 + if query_lower == sample.get('category', ''): + score += 10 # Coincidencia exacta de categoría + if query_lower in name.split('_'): + score += 5 # Palabra completa + if name.startswith(query_lower): + score += 3 # Comienza con el término + + results.append((score, sample)) + + # Ordenar por score y limitar + results.sort(key=lambda x: x[0], reverse=True) + return [sample for _, sample in results[:limit]] + + def find_by_key(self, key: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: + """Busca samples por tonalidad""" + results = [] + + for sample in self.samples: + if sample.get('key') == key: + if not category or sample['category'] == category: + results.append(sample) + + return results[:limit] + + def find_by_bpm(self, bpm: int, tolerance: int = 5, limit: int = 10) -> List[Dict[str, Any]]: + """Busca samples por BPM con tolerancia""" + results = [] + + for sample in self.samples: + sample_bpm = sample.get('bpm') + if sample_bpm and abs(sample_bpm - bpm) <= tolerance: + results.append(sample) + + return results[:limit] + + def get_random_sample(self, category: str = "") -> Optional[Dict[str, Any]]: + """Obtiene un sample aleatorio, opcionalmente filtrado por categoría""" + import random + + samples = self.samples + if category: + samples = [s for s in samples if s['category'] == category] + + return random.choice(samples) if samples else None + + def get_sample_pack(self, genre: str, key: str = "", bpm: int = 0) -> Dict[str, List[Dict]]: + """ + Obtiene un pack de samples completo para un género + + Args: + genre: Género musical + key: Tonalidad preferida + bpm: BPM preferido + + Returns: + Dict con samples organizados por categoría + """ + pack = { + 'kick': [], + 'snare': [], + 'hat': [], + 'clap': [], + 'perc': [], + 'bass': [], + 'synth': [], + 'fx': [], + } + + # Seleccionar un sample de cada categoría + for category in pack.keys(): + candidates = [s for s in self.samples if s['category'] == category] + + # Filtrar por key si se especificó + if key and candidates: + key_matches = [s for s in candidates if s.get('key') == key] + if key_matches: + candidates = key_matches + + # Filtrar por BPM si se especificó + if bpm and candidates: + bpm_matches = [s for s in candidates if s.get('bpm')] + if bpm_matches: + # Ordenar por cercanía al BPM objetivo + bpm_matches.sort(key=lambda s: abs(s['bpm'] - bpm)) + candidates = bpm_matches[:5] # Top 5 más cercanos + + # Seleccionar hasta 3 samples + import random + if candidates: + pack[category] = random.sample(candidates, min(3, len(candidates))) + + return pack + + def refresh(self): + """Reconstruye el índice desde cero""" + logger.info("Refrescando índice...") + self._build_index() + self._save_index() + + +# Función de utilidad para testing +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Uso: python sample_index.py ") + sys.exit(1) + + logging.basicConfig(level=logging.INFO) + + index = SampleIndex(sys.argv[1]) + + print(f"\nÍndice cargado: {len(index.samples)} samples") + print("\nDistribución por categoría:") + + categories = {} + for sample in index.samples: + cat = sample['category'] + categories[cat] = categories.get(cat, 0) + 1 + + for cat, count in sorted(categories.items(), key=lambda x: -x[1]): + print(f" {cat}: {count}") + + # Ejemplo de búsqueda + print("\nBúsqueda 'kick':") + for s in index.search("kick", limit=5): + print(f" - {s['name']} ({s.get('key', '?')}, {s.get('bpm', '?')} BPM)") diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py new file mode 100644 index 0000000..8ff4148 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py @@ -0,0 +1,1010 @@ +""" +sample_manager.py - Gestión completa de librería de samples + +Proporciona: +- Indexación y escaneo de directorios de samples +- Clasificación automática por tipo, key, BPM +- Gestión de metadatos y tags +- Búsqueda avanzada con filtros múltiples +- Caché de índice para rendimiento +- Soporte para múltiples formatos (WAV, AIFF, MP3, OGG, FLAC) +""" + +import json +import hashlib +import logging +from pathlib import Path +from typing import Dict, List, Any, Optional, Tuple, Callable +from dataclasses import dataclass, field, asdict +from datetime import datetime +from collections import defaultdict +import threading + +# Importar analizador de audio +try: + from .audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze + AUDIO_ANALYSIS_AVAILABLE = True +except ImportError: + try: + from audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze + AUDIO_ANALYSIS_AVAILABLE = True + except ImportError: + AUDIO_ANALYSIS_AVAILABLE = False + AudioAnalyzer = None + SampleType = None + analyze_sample = None + quick_analyze = None + +logger = logging.getLogger("SampleManager") + + +@dataclass +class Sample: + """Representa un sample en la librería""" + id: str + name: str + path: str + category: str + subcategory: str + sample_type: str + key: Optional[str] = None + bpm: Optional[float] = None + duration: float = 0.0 + sample_rate: int = 44100 + channels: int = 2 + file_size: int = 0 + format: str = "wav" + + # Metadatos adicionales + genres: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + mood: str = "" + energy: float = 0.5 # 0-1 + + # Información de análisis + analyzed: bool = False + analysis_version: int = 0 + spectral_centroid: float = 0.0 + rms_energy: float = 0.0 + is_harmonic: bool = False + is_percussive: bool = False + + # Metadatos del sistema + date_added: str = field(default_factory=lambda: datetime.now().isoformat()) + date_modified: str = field(default_factory=lambda: datetime.now().isoformat()) + play_count: int = 0 + rating: int = 0 # 0-5 + + def to_dict(self) -> Dict[str, Any]: + """Convierte el sample a diccionario""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Sample': + """Crea un Sample desde un diccionario""" + # Filtrar solo los campos que existen en la clase + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered_data = {k: v for k, v in data.items() if k in valid_fields} + return cls(**filtered_data) + + def get_display_name(self) -> str: + """Nombre formateado para mostrar""" + parts = [self.name] + if self.key: + parts.append(f"Key: {self.key}") + if self.bpm: + parts.append(f"{self.bpm:.1f} BPM") + return " | ".join(parts) + + +class SampleManager: + """ + Gestor principal de la librería de samples. + + Características: + - Indexación recursiva de directorios + - Clasificación automática por tipo + - Detección de key y BPM (si librosa está disponible) + - Búsqueda avanzada con múltiples filtros + - Sistema de favoritos y ratings + - Caché persistente en JSON + """ + + # Categorías principales y subcategorías + CATEGORIES = { + 'drums': { + 'kick': ['kick', 'bd', 'bass drum', 'kickdrum'], + 'snare': ['snare', 'snr', 'sd', 'rimshot'], + 'clap': ['clap', 'clp', 'handclap'], + 'hat_closed': ['closed hat', 'chh', 'closed'], + 'hat_open': ['open hat', 'ohh', 'open'], + 'hat': ['hat', 'hihat', 'hi-hat'], + 'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale'], + 'shaker': ['shaker', 'tambourine', 'tamb'], + 'tom': ['tom', 'tomtom'], + 'cymbal': ['crash', 'ride', 'cymbal', 'china'], + }, + 'bass': { + 'sub': ['sub', 'subbass', '808'], + 'bassline': ['bassline', 'bass', 'reese'], + 'acid': ['acid', 'tb303', '303'], + }, + 'synths': { + 'lead': ['lead', 'solo', 'main'], + 'pad': ['pad', 'atmosphere', 'dron', 'ambient'], + 'pluck': ['pluck', 'arp', 'arpeggio'], + 'chord': ['chord', 'stab', 'hit'], + 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter'], + }, + 'vocals': { + 'vocal': ['vocal', 'vox', 'voice'], + 'speech': ['speech', 'talk', 'phrase'], + 'chant': ['chant', 'shout', 'yell'], + }, + 'loops': { + 'drum_loop': ['drum loop', 'beat loop', 'groove'], + 'perc_loop': ['perc loop', 'percussion loop'], + 'bass_loop': ['bass loop', 'bassline loop'], + 'synth_loop': ['synth loop', 'lead loop'], + 'full_loop': ['full loop', 'complete loop'], + }, + 'one_shots': { + 'hit': ['hit', 'impact', 'sting'], + 'noise': ['noise', 'texture', 'grain'], + } + } + + # Mapeo de extensiones de archivo + SUPPORTED_FORMATS = {'.wav', '.aif', '.aiff', '.mp3', '.ogg', '.flac', '.m4a'} + + # Géneros soportados con palabras clave + GENRE_KEYWORDS = { + 'house': ['house', 'deep', 'soulful', 'garage', 'classic'], + 'techno': ['techno', 'industrial', 'detroit', 'berlin', 'acid'], + 'tech-house': ['tech house', 'tech-house', 'groovy', 'bouncy'], + 'trance': ['trance', 'progressive', 'uplifting', 'psy'], + 'drum-and-bass': ['drum and bass', 'dnb', 'neuro', 'liquid', 'jungle'], + 'hip-hop': ['hip hop', 'hiphop', 'trap', 'boom bap', 'lofi'], + 'ambient': ['ambient', 'chillout', 'downtempo', 'meditation'], + 'edm': ['edm', 'electro', 'big room', 'festival'], + } + + def __init__(self, base_dir: str, cache_dir: Optional[str] = None): + """ + Inicializa el gestor de samples. + + Args: + base_dir: Directorio raíz de la librería de samples + cache_dir: Directorio para caché (default: base_dir/.sample_cache) + """ + self.base_dir = Path(base_dir) + self.cache_dir = Path(cache_dir) if cache_dir else self.base_dir / ".sample_cache" + self.cache_dir.mkdir(exist_ok=True) + + self.samples: Dict[str, Sample] = {} + self.index_file = self.cache_dir / "sample_library.json" + self.stats_file = self.cache_dir / "library_stats.json" + + # Analizador de audio + self.analyzer = AudioAnalyzer() if AUDIO_ANALYSIS_AVAILABLE else None + + # Locks para thread-safety + self._lock = threading.RLock() + self._index_dirty = False + + # Estadísticas + self.stats = { + 'total_samples': 0, + 'total_size': 0, + 'by_category': defaultdict(int), + 'by_key': defaultdict(int), + 'by_bpm_range': defaultdict(int), + 'last_scan': None, + } + + # Cargar índice existente + self._load_index() + + def _generate_id(self, file_path: str) -> str: + """Genera un ID único para un sample basado en su ruta""" + return hashlib.md5(file_path.encode()).hexdigest()[:16] + + def _get_file_hash(self, file_path: Path) -> str: + """Calcula hash del archivo para detectar cambios""" + stat = file_path.stat() + return hashlib.md5(f"{stat.st_size}_{stat.st_mtime}".encode()).hexdigest() + + def scan_directory(self, directory: Optional[str] = None, + recursive: bool = True, + analyze_audio: bool = False, + progress_callback: Optional[Callable[[int, int, str], None]] = None) -> Dict[str, Any]: + """ + Escanear un directorio en busca de samples. + + Args: + directory: Directorio a escanear (default: base_dir) + recursive: Escanear subdirectorios + analyze_audio: Analizar contenido de audio (más lento) + progress_callback: Función llamada con (procesados, total, archivo_actual) + + Returns: + Estadísticas del escaneo + """ + scan_dir = Path(directory) if directory else self.base_dir + + if not scan_dir.exists(): + raise FileNotFoundError(f"Directorio no encontrado: {scan_dir}") + + logger.info(f"Escaneando: {scan_dir}") + + # Encontrar todos los archivos de audio + if recursive: + audio_files = list(scan_dir.rglob('*')) + else: + audio_files = list(scan_dir.iterdir()) + + audio_files = [f for f in audio_files + if f.is_file() and f.suffix.lower() in self.SUPPORTED_FORMATS] + + total = len(audio_files) + processed = 0 + added = 0 + updated = 0 + errors = 0 + + logger.info(f"Encontrados {total} archivos de audio") + + with self._lock: + for file_path in audio_files: + processed += 1 + + if progress_callback: + progress_callback(processed, total, str(file_path.name)) + + try: + result = self._process_file(file_path, analyze_audio) + if result == 'added': + added += 1 + elif result == 'updated': + updated += 1 + + except Exception as e: + logger.error(f"Error procesando {file_path}: {e}") + errors += 1 + + self._index_dirty = True + self._update_stats() + self._save_index() + + self.stats['last_scan'] = datetime.now().isoformat() + + return { + 'processed': processed, + 'added': added, + 'updated': updated, + 'errors': errors, + 'total_samples': len(self.samples), + } + + def _process_file(self, file_path: Path, analyze_audio: bool) -> str: + """Procesa un archivo individual. Retorna 'added', 'updated', o 'unchanged'""" + file_id = self._generate_id(str(file_path)) + self._get_file_hash(file_path) + + # Verificar si ya existe y no ha cambiado + if file_id in self.samples: + existing = self.samples[file_id] + # Comparar hash implícito por fecha de modificación + current_stat = file_path.stat() + if existing.date_modified: + try: + mod_time = datetime.fromisoformat(existing.date_modified).timestamp() + if abs(current_stat.st_mtime - mod_time) < 1: + return 'unchanged' + except Exception: + pass + + # Extraer información del nombre + name = file_path.stem + category, subcategory = self._classify_by_name(name) + sample_type = self._detect_sample_type(name) + key = self._extract_key_from_name(name) + bpm = self._extract_bpm_from_name(name) + genres = self._detect_genres(name) + + # Análisis de audio si está disponible + audio_features = {} + if analyze_audio and self.analyzer: + try: + audio_features = analyze_sample(str(file_path)) + # Usar valores detectados si no están en el nombre + if not bpm and audio_features.get('bpm'): + bpm = audio_features['bpm'] + if not key and audio_features.get('key'): + key = audio_features['key'] + if audio_features.get('sample_type'): + sample_type = audio_features['sample_type'] + if audio_features.get('suggested_genres'): + genres = list(set(genres + audio_features['suggested_genres'])) + except Exception as e: + logger.warning(f"Error analizando audio {file_path}: {e}") + + # Crear o actualizar sample + is_new = file_id not in self.samples + + sample = Sample( + id=file_id, + name=name, + path=str(file_path), + category=category, + subcategory=subcategory, + sample_type=sample_type, + key=key, + bpm=bpm, + duration=audio_features.get('duration', 0.0), + sample_rate=audio_features.get('sample_rate', 44100), + file_size=file_path.stat().st_size, + format=file_path.suffix.lower().lstrip('.'), + genres=genres, + tags=self._extract_tags(name), + analyzed=analyze_audio, + spectral_centroid=audio_features.get('spectral_centroid', 0.0), + rms_energy=audio_features.get('rms_energy', 0.0), + is_harmonic=audio_features.get('is_harmonic', False), + is_percussive=audio_features.get('is_percussive', False), + date_modified=datetime.now().isoformat(), + ) + + self.samples[file_id] = sample + return 'added' if is_new else 'updated' + + def _classify_by_name(self, name: str) -> Tuple[str, str]: + """Clasifica un sample por su nombre en categoría y subcategoría""" + name_lower = name.lower() + + for category, subcategories in self.CATEGORIES.items(): + for subcategory, keywords in subcategories.items(): + for keyword in keywords: + if keyword in name_lower: + return category, subcategory + + # Fallback: intentar detectar loops + if 'loop' in name_lower: + return 'loops', 'unknown' + + return 'unknown', 'unknown' + + def _detect_sample_type(self, name: str) -> str: + """Detecta el tipo específico de sample""" + category, subcategory = self._classify_by_name(name) + + if category == 'drums': + return subcategory + elif category == 'bass': + return f"bass_{subcategory}" + elif category == 'synths': + return subcategory + elif category == 'vocals': + return subcategory + elif category == 'loops': + return subcategory + + return 'unknown' + + def _extract_key_from_name(self, name: str) -> Optional[str]: + """Extrae la tonalidad del nombre del archivo""" + import re + + # Patrones comunes + patterns = [ + r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', + r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', + r'Key[_\s]?([A-G][#b]?(?:m|min|minor)?)', + r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + key = match.group(1) + # Normalizar bemoles a sostenidos + key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + # Detectar modo + is_minor = 'm' in key.lower() or 'min' in key.lower() + key = key.replace('min', '').replace('minor', '').replace('major', '') + key = key.rstrip('mM') + + if is_minor: + key = key + 'm' + + return key + + return None + + def _extract_bpm_from_name(self, name: str) -> Optional[float]: + """Extrae el BPM del nombre del archivo""" + import re + + patterns = [ + r'[_\s\-](\d{2,3})\s*BPM', + r'[_\s\-](\d{2,3})[_\s\-]', + r'(\d{2,3})bpm', + ] + + for pattern in patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + bpm = int(match.group(1)) + if 60 <= bpm <= 200: + return float(bpm) + + return None + + def _detect_genres(self, name: str) -> List[str]: + """Detecta géneros musicales del nombre""" + name_lower = name.lower() + genres = [] + + for genre, keywords in self.GENRE_KEYWORDS.items(): + for keyword in keywords: + if keyword in name_lower: + genres.append(genre) + break + + return genres + + def _extract_tags(self, name: str) -> List[str]: + """Extrae tags del nombre del archivo""" + import re + + tags = [] + name_lower = name.lower() + + # Palabras comunes como tags + common_tags = [ + 'dry', 'wet', 'processed', 'raw', 'analog', 'digital', + 'vintage', 'modern', 'punchy', 'deep', 'bright', 'dark', + 'tight', 'loose', 'fat', 'thin', 'crisp', 'warm', + 'one shot', 'loop', 'sample', 'hit' + ] + + for tag in common_tags: + if tag in name_lower: + tags.append(tag.replace(' ', '_')) + + # Extraer números como versiones + numbers = re.findall(r'\d+', name) + for num in numbers: + if len(num) <= 2: # Probablemente versión + tags.append(f"v{num}") + + return list(set(tags)) + + def search(self, + query: str = "", + category: str = "", + subcategory: str = "", + sample_type: str = "", + key: str = "", + bpm: Optional[float] = None, + bpm_tolerance: int = 5, + genres: List[str] = None, + tags: List[str] = None, + min_rating: int = 0, + favorites_only: bool = False, + limit: int = 50, + sort_by: str = "name") -> List[Sample]: + """ + Búsqueda avanzada de samples con múltiples filtros. + + Args: + query: Búsqueda por nombre + category: Categoría principal + subcategory: Subcategoría + sample_type: Tipo específico + key: Tonalidad musical + bpm: BPM objetivo + bpm_tolerance: Tolerancia de BPM (+/-) + genres: Lista de géneros + tags: Lista de tags + min_rating: Rating mínimo + favorites_only: Solo favoritos + limit: Límite de resultados + sort_by: Campo para ordenar + + Returns: + Lista de samples que coinciden + """ + with self._lock: + results = [] + query_lower = query.lower() + + for sample in self.samples.values(): + # Filtro por query (nombre) + if query and query_lower not in sample.name.lower(): + continue + + # Filtros de categoría + if category and sample.category != category.lower(): + continue + if subcategory and sample.subcategory != subcategory.lower(): + continue + if sample_type and sample.sample_type != sample_type.lower(): + continue + + # Filtro por key + if key: + sample_key = (sample.key or "").lower() + if sample_key != key.lower(): + # Intentar key compatible (mismo root) + if not sample_key.startswith(key.lower().rstrip('m')): + continue + + # Filtro por BPM + if bpm is not None and sample.bpm: + if abs(sample.bpm - bpm) > bpm_tolerance: + continue + + # Filtro por géneros + if genres: + sample_genres = [g.lower() for g in sample.genres] + if not any(g.lower() in sample_genres for g in genres): + continue + + # Filtro por tags + if tags: + sample_tags = [t.lower() for t in sample.tags] + if not any(t.lower() in sample_tags for t in tags): + continue + + # Filtro por rating + if min_rating > 0 and sample.rating < min_rating: + continue + + # Filtro de favoritos + if favorites_only and sample.rating < 4: + continue + + results.append(sample) + + # Ordenar resultados + if sort_by == "name": + results.sort(key=lambda s: s.name.lower()) + elif sort_by == "bpm": + results.sort(key=lambda s: s.bpm or 0) + elif sort_by == "rating": + results.sort(key=lambda s: s.rating, reverse=True) + elif sort_by == "date_added": + results.sort(key=lambda s: s.date_added, reverse=True) + + return results[:limit] + + def get_by_id(self, sample_id: str) -> Optional[Sample]: + """Obtiene un sample por su ID""" + with self._lock: + return self.samples.get(sample_id) + + def get_by_path(self, file_path: str) -> Optional[Sample]: + """Obtiene un sample por su ruta""" + sample_id = self._generate_id(file_path) + return self.get_by_id(sample_id) + + def get_random(self, category: str = "", limit: int = 1) -> List[Sample]: + """Obtiene samples aleatorios""" + import random + + with self._lock: + samples = list(self.samples.values()) + + if category: + samples = [s for s in samples if s.category == category] + + if not samples: + return [] + + return random.sample(samples, min(limit, len(samples))) + + def get_pack_for_genre(self, genre: str, key: str = "", + bpm: Optional[float] = None) -> Dict[str, List[Sample]]: + """ + Obtiene un pack completo de samples para un género específico. + + Returns: + Dict con samples organizados por tipo + """ + pack = { + 'kicks': [], + 'snares': [], + 'claps': [], + 'hats': [], + 'percussion': [], + 'bass': [], + 'synths': [], + 'fx': [], + } + + # Buscar samples por tipo + type_mapping = { + 'kicks': ['kick'], + 'snares': ['snare'], + 'claps': ['clap'], + 'hats': ['hat', 'hat_closed', 'hat_open'], + 'percussion': ['perc', 'shaker', 'tom', 'cymbal'], + 'bass': ['bass', 'sub', 'bassline', 'acid'], + 'synths': ['lead', 'pad', 'pluck', 'chord'], + 'fx': ['fx', 'hit', 'noise'], + } + + for pack_category, sample_types in type_mapping.items(): + for sample_type in sample_types: + samples = self.search( + sample_type=sample_type, + key=key, + bpm=bpm, + genres=[genre] if genre else None, + limit=5 + ) + + if samples: + pack[pack_category].extend(samples) + + return pack + + def update_sample(self, sample_id: str, **kwargs) -> bool: + """ + Actualiza metadatos de un sample. + + Args: + sample_id: ID del sample + **kwargs: Campos a actualizar + """ + with self._lock: + if sample_id not in self.samples: + return False + + sample = self.samples[sample_id] + + # Campos permitidos para actualización + allowed_fields = { + 'rating', 'tags', 'genres', 'mood', 'energy', + 'key', 'bpm', 'play_count' + } + + for field, value in kwargs.items(): + if field in allowed_fields and hasattr(sample, field): + setattr(sample, field, value) + + sample.date_modified = datetime.now().isoformat() + self._index_dirty = True + + return True + + def rate_sample(self, sample_id: str, rating: int) -> bool: + """Califica un sample (1-5 estrellas)""" + if 0 <= rating <= 5: + return self.update_sample(sample_id, rating=rating) + return False + + def increment_play_count(self, sample_id: str) -> bool: + """Incrementa el contador de reproducciones""" + sample = self.get_by_id(sample_id) + if sample: + return self.update_sample(sample_id, play_count=sample.play_count + 1) + return False + + def delete_sample(self, sample_id: str, delete_file: bool = False) -> bool: + """ + Elimina un sample del índice. + + Args: + sample_id: ID del sample + delete_file: Si True, también elimina el archivo físico + """ + with self._lock: + if sample_id not in self.samples: + return False + + sample = self.samples[sample_id] + + if delete_file: + try: + Path(sample.path).unlink() + except Exception as e: + logger.error(f"Error eliminando archivo: {e}") + return False + + del self.samples[sample_id] + self._index_dirty = True + self._update_stats() + + return True + + def refresh(self, analyze_audio: bool = False) -> Dict[str, Any]: + """Refresca el índice completo""" + logger.info("Refrescando índice de samples...") + + # Guardar IDs actuales para detectar eliminados + current_paths = {s.path for s in self.samples.values()} + + # Re-escanear + stats = self.scan_directory(analyze_audio=analyze_audio) + + # Detectar archivos eliminados + new_paths = {s.path for s in self.samples.values()} + removed = current_paths - new_paths + + for path in removed: + sample_id = self._generate_id(path) + if sample_id in self.samples: + del self.samples[sample_id] + stats['removed'] = stats.get('removed', 0) + 1 + + self._save_index() + return stats + + def get_stats(self) -> Dict[str, Any]: + """Obtiene estadísticas de la librería""" + with self._lock: + return { + 'total_samples': len(self.samples), + 'total_size': sum(s.file_size for s in self.samples.values()), + 'by_category': dict(self.stats['by_category']), + 'by_key': dict(self.stats['by_key']), + 'by_bpm_range': dict(self.stats['by_bpm_range']), + 'last_scan': self.stats['last_scan'], + } + + def export_library(self, output_path: str, format: str = "json") -> str: + """ + Exporta la librería a un archivo. + + Args: + output_path: Ruta del archivo de salida + format: 'json' o 'csv' + + Returns: + Ruta del archivo exportado + """ + output = Path(output_path) + + with self._lock: + if format == "json": + data = { + 'export_date': datetime.now().isoformat(), + 'stats': self.get_stats(), + 'samples': [s.to_dict() for s in self.samples.values()] + } + with open(output, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + elif format == "csv": + import csv + with open(output, 'w', newline='', encoding='utf-8') as f: + if self.samples: + writer = csv.DictWriter(f, fieldnames=self.samples[list(self.samples.keys())[0]].to_dict().keys()) + writer.writeheader() + for sample in self.samples.values(): + writer.writerow(sample.to_dict()) + + return str(output) + + def import_library(self, input_path: str, merge: bool = True) -> Dict[str, int]: + """ + Importa una librería desde un archivo JSON. + + Args: + input_path: Ruta del archivo a importar + merge: Si True, mezcla con la librería existente + + Returns: + Estadísticas de la importación + """ + with open(input_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + imported_samples = data.get('samples', []) + + with self._lock: + if not merge: + self.samples.clear() + + added = 0 + updated = 0 + + for sample_data in imported_samples: + try: + sample = Sample.from_dict(sample_data) + if sample.id in self.samples: + updated += 1 + else: + added += 1 + self.samples[sample.id] = sample + except Exception as e: + logger.error(f"Error importando sample: {e}") + + self._index_dirty = True + self._update_stats() + self._save_index() + + return {'added': added, 'updated': updated} + + def _update_stats(self): + """Actualiza las estadísticas de la librería""" + self.stats['total_samples'] = len(self.samples) + self.stats['total_size'] = sum(s.file_size for s in self.samples.values()) + + # Resetear contadores + self.stats['by_category'] = defaultdict(int) + self.stats['by_key'] = defaultdict(int) + self.stats['by_bpm_range'] = defaultdict(int) + + for sample in self.samples.values(): + self.stats['by_category'][sample.category] += 1 + + if sample.key: + self.stats['by_key'][sample.key] += 1 + + if sample.bpm: + if sample.bpm < 100: + self.stats['by_bpm_range']['slow (<100)'] += 1 + elif sample.bpm < 128: + self.stats['by_bpm_range']['mid (100-128)'] += 1 + elif sample.bpm < 140: + self.stats['by_bpm_range']['fast (128-140)'] += 1 + else: + self.stats['by_bpm_range']['very fast (>140)'] += 1 + + def _load_index(self): + """Carga el índice desde disco""" + if not self.index_file.exists(): + logger.info("No existe índice previo, iniciando librería vacía") + return + + try: + with open(self.index_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + for sample_data in data.get('samples', []): + try: + sample = Sample.from_dict(sample_data) + self.samples[sample.id] = sample + except Exception as e: + logger.warning(f"Error cargando sample: {e}") + + self.stats = data.get('stats', self.stats) + logger.info(f"Índice cargado: {len(self.samples)} samples") + + except Exception as e: + logger.error(f"Error cargando índice: {e}") + + def _save_index(self): + """Guarda el índice a disco""" + if not self._index_dirty: + return + + try: + data = { + 'version': 1, + 'saved_at': datetime.now().isoformat(), + 'stats': self.get_stats(), + 'samples': [s.to_dict() for s in self.samples.values()] + } + + # Guardar a archivo temporal primero + temp_file = self.index_file.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Renombrar atómicamente + temp_file.replace(self.index_file) + + self._index_dirty = False + logger.info(f"Índice guardado: {len(self.samples)} samples") + + except Exception as e: + logger.error(f"Error guardando índice: {e}") + + def save(self): + """Fuerza el guardado del índice""" + self._index_dirty = True + self._save_index() + + +# Instancia global +_manager: Optional[SampleManager] = None + + +def get_manager(base_dir: Optional[str] = None) -> SampleManager: + """Obtiene la instancia global del gestor""" + global _manager + if _manager is None: + if base_dir is None: + PACKAGE_DIR = Path(__file__).resolve().parent.parent + base_dir = str(Path(r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks")) + _manager = SampleManager(base_dir) + return _manager + + +# Funciones de conveniencia +def scan_samples(directory: str, analyze_audio: bool = False) -> Dict[str, Any]: + """Escanear directorio de samples""" + manager = get_manager(directory) + return manager.scan_directory(analyze_audio=analyze_audio) + + +def find_samples(query: str = "", **kwargs) -> List[Dict[str, Any]]: + """Buscar samples""" + manager = get_manager() + samples = manager.search(query=query, **kwargs) + return [s.to_dict() for s in samples] + + +def get_sample_pack(genre: str, key: str = "", bpm: Optional[float] = None) -> Dict[str, List[Dict]]: + """Obtener pack de samples para un género""" + manager = get_manager() + pack = manager.get_pack_for_genre(genre, key, bpm) + return {k: [s.to_dict() for s in v] for k, v in pack.items()} + + +# Testing +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO) + + if len(sys.argv) < 2: + print("Uso: python sample_manager.py [comando]") + print("\nComandos:") + print(" scan - Escanear directorio") + print(" stats - Mostrar estadísticas") + print(" search - Buscar samples") + sys.exit(1) + + directory = sys.argv[1] + command = sys.argv[2] if len(sys.argv) > 2 else "scan" + + manager = SampleManager(directory) + + if command == "scan": + print(f"\nEscaneando: {directory}") + print("=" * 50) + + def progress(current, total, filename): + pct = (current / total) * 100 + print(f"\r[{pct:5.1f}%] {filename[:50]:<50}", end="", flush=True) + + stats = manager.scan_directory(progress_callback=progress) + print("\n") + print(f"Procesados: {stats['processed']}") + print(f"Agregados: {stats['added']}") + print(f"Actualizados: {stats['updated']}") + print(f"Errores: {stats['errors']}") + print(f"Total en librería: {stats['total_samples']}") + + elif command == "stats": + stats = manager.get_stats() + print("\nEstadísticas de la librería:") + print("=" * 50) + print(f"Total samples: {stats['total_samples']}") + print(f"Tamaño total: {stats['total_size'] / (1024**2):.1f} MB") + print(f"Último escaneo: {stats['last_scan']}") + print("\nPor categoría:") + for cat, count in sorted(stats['by_category'].items()): + print(f" {cat}: {count}") + print("\nPor key:") + for key, count in sorted(stats['by_key'].items()): + print(f" {key}: {count}") + + elif command == "search": + query = sys.argv[3] if len(sys.argv) > 3 else "" + print(f"\nBuscando: '{query}'") + print("=" * 50) + + results = manager.search(query=query, limit=20) + for s in results: + print(f"\n{s.name}") + print(f" Categoría: {s.category}/{s.subcategory}") + print(f" Key: {s.key or 'N/A'} | BPM: {s.bpm or 'N/A'}") + print(f" Path: {s.path}") diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py new file mode 100644 index 0000000..3e70974 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py @@ -0,0 +1,244 @@ +""" +Demo del Sistema de Gestión de Samples para AbletonMCP-AI + +Este script demuestra las capacidades del sistema completo de samples. +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from sample_manager import get_manager +from sample_selector import get_selector +from audio_analyzer import analyze_sample, AudioAnalyzer + + +def demo_analyzer(): + """Demostración del analizador de audio""" + print("=" * 60) + print("DEMO: Audio Analyzer") + print("=" * 60) + + AudioAnalyzer(backend='basic') + + # Analizar un archivo de ejemplo + test_file = r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav" + + print(f"\nAnalizando: {Path(test_file).name}") + print("-" * 40) + + try: + result = analyze_sample(test_file) + + print(f"Tipo detectado: {result['sample_type']}") + print(f"BPM: {result.get('bpm') or 'No detectado'}") + print(f"Key: {result.get('key') or 'No detectado'}") + print(f"Duración: {result['duration']:.3f}s") + print(f"Es percusivo: {result['is_percussive']}") + print(f"Géneros sugeridos: {', '.join(result['suggested_genres'])}") + + except Exception as e: + print(f"Error: {e}") + + print() + + +def demo_manager(): + """Demostración del gestor de samples""" + print("=" * 60) + print("DEMO: Sample Manager") + print("=" * 60) + + manager = get_manager(r"C:\Users\ren\embeddings\all_tracks") + + # Escanear librería + print("\nEscaneando librería...") + stats = manager.scan_directory() + print(f" Samples procesados: {stats['processed']}") + print(f" Nuevos: {stats['added']}") + print(f" Total en librería: {stats['total_samples']}") + + # Estadísticas + print("\nEstadísticas:") + stats = manager.get_stats() + print(f" Total: {stats['total_samples']} samples") + print(f" Tamaño: {stats['total_size'] / (1024**2):.1f} MB") + + if stats['by_category']: + print("\n Por categoría:") + for cat, count in sorted(stats['by_category'].items(), key=lambda x: -x[1]): + print(f" {cat}: {count}") + + if stats['by_key']: + print("\n Por key:") + for key, count in sorted(stats['by_key'].items(), key=lambda x: -x[1]): + print(f" {key}: {count}") + + # Búsquedas + print("\nBúsquedas:") + print("-" * 40) + + # Buscar kicks + kicks = manager.search(sample_type="kick", limit=3) + print(f"\nKicks encontrados: {len(kicks)}") + for s in kicks: + print(f" - {s.name}") + + # Buscar por key + g_sharp = manager.search(key="G#m", limit=3) + print(f"\nSamples en G#m: {len(g_sharp)}") + for s in g_sharp: + print(f" - {s.name} ({s.sample_type})") + + # Buscar por BPM + bpm_128 = manager.search(bpm=128, bpm_tolerance=5, limit=3) + print(f"\nSamples ~128 BPM: {len(bpm_128)}") + for s in bpm_128: + key_info = f" [{s.key}]" if s.key else "" + print(f" - {s.name}{key_info}") + + print() + + +def demo_selector(): + """Demostración del selector inteligente""" + print("=" * 60) + print("DEMO: Sample Selector") + print("=" * 60) + + selector = get_selector() + + # Seleccionar para diferentes géneros + genres = ['techno', 'house', 'tech-house'] + + for genre in genres: + print(f"\n{genre.upper()}:") + print("-" * 40) + + group = selector.select_for_genre(genre, key='Am', bpm=128) + + print(f" Key: {group.key} | BPM: {group.bpm}") + + # Drum kit + kit = group.drums + print("\n Drum Kit:") + if kit.kick: + print(f" Kick: {kit.kick.name}") + if kit.snare: + print(f" Snare: {kit.snare.name}") + if kit.clap: + print(f" Clap: {kit.clap.name}") + if kit.hat_closed: + print(f" Hat: {kit.hat_closed.name}") + + # Mapeo MIDI + mapping = selector.get_midi_mapping_for_kit(kit) + print("\n Mapeo MIDI:") + for note, info in sorted(mapping['notes'].items())[:4]: + if info['sample']: + print(f" Note {note}: {info['sample'][:40]}...") + + # Bass + if group.bass: + print(f"\n Bass ({len(group.bass)}):") + for s in group.bass[:2]: + key_info = f" [{s.key}]" if s.key else "" + print(f" - {s.name}{key_info}") + + # Cambio de key + print("\n" + "-" * 40) + print("Cambios de Key Sugeridos (desde Am):") + changes = ['fifth_up', 'fifth_down', 'relative', 'parallel'] + for change in changes: + new_key = selector.suggest_key_change('Am', change) + print(f" {change}: {new_key}") + + print() + + +def demo_compatibility(): + """Demostración de búsqueda de samples compatibles""" + print("=" * 60) + print("DEMO: Compatibilidad de Samples") + print("=" * 60) + + manager = get_manager() + selector = get_selector() + + # Encontrar un sample con key para usar de referencia + samples_with_key = manager.search(key="G#m", limit=1) + + if samples_with_key: + reference = samples_with_key[0] + print(f"\nSample de referencia: {reference.name}") + print(f" Key: {reference.key} | BPM: {reference.bpm}") + + # Buscar compatibles + compatible = selector.find_compatible_samples(reference, max_results=5) + + print("\nSamples compatibles:") + print("-" * 40) + + for sample, score in compatible: + bar_len = int(score * 20) + bar = "█" * bar_len + "░" * (20 - bar_len) + print(f" [{bar}] {score:.1%} - {sample.name}") + + print() + + +def demo_pack_generation(): + """Demostración de generación de packs""" + print("=" * 60) + print("DEMO: Generación de Sample Packs") + print("=" * 60) + + manager = get_manager() + + genres = ['techno', 'house', 'deep-house'] + + for genre in genres: + print(f"\n{genre.upper()} Pack:") + print("-" * 40) + + pack = manager.get_pack_for_genre(genre, key='Am', bpm=128) + + total = 0 + for category, samples in pack.items(): + if samples: + count = len(samples) + total += count + print(f" {category}: {count}") + + print(f" Total: {total} samples") + + print() + + +def main(): + """Ejecutar todas las demos""" + print("\n") + print("=" * 60) + print(" AbletonMCP-AI Sample System Demo ".center(60)) + print("=" * 60) + print() + + try: + demo_analyzer() + demo_manager() + demo_selector() + demo_compatibility() + demo_pack_generation() + + print("=" * 60) + print("Todas las demos completadas exitosamente!") + print("=" * 60) + + except Exception as e: + print(f"\nError en demo: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py new file mode 100644 index 0000000..3f97f68 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py @@ -0,0 +1,198 @@ +""" +segment_rag_builder.py - Build or refresh the persistent segment-audio index. +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path + +from reference_listener import ReferenceAudioListener, export_segment_rag_manifest, generate_segment_rag_summary, _get_segment_rag_status, _backfill_segment_cache_metadata + + +logger = logging.getLogger(__name__) + + +def _default_library_dir() -> Path: + return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build the persistent segment-audio retrieval cache.") + parser.add_argument("--library-dir", default=str(_default_library_dir()), help="Audio library directory") + parser.add_argument("--roles", nargs="*", default=None, help="Subset of roles to index") + parser.add_argument("--max-files", type=int, default=None, help="Optional limit for targeted files") + parser.add_argument("--duration-limit", type=float, default=24.0, help="Max seconds per file during indexing") + parser.add_argument("--force", action="store_true", help="Rebuild even if persistent segment cache already exists") + parser.add_argument("--json", action="store_true", help="Emit full JSON report") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + parser.add_argument("--offset", type=int, default=0, help="Skip first N files before starting (for chunked indexing)") + parser.add_argument("--batch-size", type=int, default=None, help="Process exactly N files then stop (for chunked indexing)") + parser.add_argument("--output-manifest", type=str, default=None, help="Path to save full manifest JSON") + parser.add_argument("--output-summary", type=str, default=None, help="Path to save summary report") + parser.add_argument("--resume", action="store_true", help="Resume from previous run state") + parser.add_argument("--export-manifest", type=str, default=None, + help="Export candidate manifest to FILE (format: .json or .md)") + parser.add_argument("--export-format", type=str, default="json", + choices=['json', 'markdown'], help="Manifest export format") + parser.add_argument("--status", action="store_true", help="Show current index status without building") + parser.add_argument("--backfill-metadata", action="store_true", help="Backfill metadata into existing cache files from indexing state") + parser.add_argument("--force-backfill", action="store_true", help="Force backfill even for files that already have metadata") + args = parser.parse_args() + + # Configure logging based on verbose flag + if args.verbose: + logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s') + else: + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + + # Handle --status flag for early exit + if args.status: + status = _get_segment_rag_status(Path(args.library_dir)) + + if args.json: + print(json.dumps(status, indent=2, default=str)) + else: + print("=" * 60) + print("SEGMENT RAG INDEX STATUS") + print("=" * 60) + print(f"Cache Directory: {status['cache_dir']}") + print(f"Cache Files: {status['cache_files']}") + print(f"Total Indexed Segments: {status['total_segments']}") + print(f"Status: {status.get('status', 'unknown')}") + + if status.get('role_coverage'): + print("\nRole Coverage:") + for role, count in sorted(status['role_coverage'].items()): + print(f" {role}: {count} segments") + + if status.get('newest_entries'): + print(f"\nNewest Entries: {len(status['newest_entries'])} files") + for entry in status['newest_entries'][:5]: + print(f" - {entry['file_name']} ({entry['segments']} segments)") + + if status.get('oldest_entries'): + print(f"\nOldest Entries: {len(status['oldest_entries'])} files") + for entry in status['oldest_entries'][:5]: + print(f" - {entry['file_name']} ({entry['segments']} segments)") + + return 0 + + # Handle --backfill-metadata flag for early exit + if args.backfill_metadata: + result = _backfill_segment_cache_metadata(Path(args.library_dir), force=args.force_backfill) + + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print("=" * 60) + print("SEGMENT CACHE METADATA BACKFILL") + print("=" * 60) + print(f"Cache Directory: {result['cache_dir']}") + print(f"Cache Files: {result['cache_files']}") + print(f"Backfilled: {result['backfilled']}") + print(f"Skipped: {result['skipped']}") + print(f"Errors: {result['errors']}") + print(f"Status: {result.get('status', 'unknown')}") + + return 0 + + listener = ReferenceAudioListener(args.library_dir) + report = listener.build_segment_rag_index( + roles=args.roles, + max_files=args.max_files, + duration_limit=args.duration_limit, + force=args.force, + offset=args.offset, + batch_size=args.batch_size, + resume=args.resume, + ) + + # Generate enhanced summary + summary = generate_segment_rag_summary(report, Path(args.library_dir)) + + if args.json: + print(json.dumps(summary, indent=2, default=str)) + else: + # Enhanced text output + print("=" * 60) + print("SEGMENT RAG INDEX COMPLETE") + print("=" * 60) + print(f"Device: {summary['device']}") + print(f"Cache: {summary['segment_index_dir']}") + print() + print(f"Files: {summary['files_targeted']} targeted") + print(f" Built: {summary['built']}") + print(f" Reused: {summary['reused']}") + print(f" Skipped: {summary['skipped']}") + print(f" Errors: {summary['errors']}") + print() + print(f"Total Segments: {summary['total_segments']}") + + if 'summary_stats' in summary: + stats = summary['summary_stats'] + print(f" Avg per file: {stats['avg_segments_per_file']:.1f}") + print(f" Range: {stats['min_segments']} - {stats['max_segments']}") + + if 'role_coverage' in summary: + print("\nRole Coverage:") + for role in sorted(summary['role_coverage'].keys()): + print(f" {role}: {summary['role_coverage'][role]} segments") + + if 'cache_info' in summary: + info = summary['cache_info'] + print(f"\nCache Size: {info['cache_size_mb']} MB") + + if args.offset > 0: + print(f"\nOffset: {args.offset}") + if args.batch_size is not None: + print(f"Batch Size: {args.batch_size}") + print(f"Files Remaining: {summary.get('files_remaining', 'unknown')}") + + # Save manifest if requested + if args.output_manifest: + manifest_path = Path(args.output_manifest) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + with open(manifest_path, 'w') as f: + json.dump({ + "report": report, + "full_manifest": report.get("manifest", []), + }, f, indent=2) + if not args.json: + print(f"\nManifest saved to: {manifest_path}") + + # Save summary if requested + if args.output_summary: + summary_path = Path(args.output_summary) + summary_path.parent.mkdir(parents=True, exist_ok=True) + with open(summary_path, 'w') as f: + json.dump(summary, f, indent=2, default=str) + if not args.json: + print(f"Summary saved to: {summary_path}") + + # Export manifest in requested format + if args.export_manifest: + manifest_path = Path(args.export_manifest) + export_format = args.export_format + + # Determine format from extension if not specified + if not args.export_format or args.export_format == "json": + if manifest_path.suffix == '.md': + export_format = 'markdown' + else: + export_format = 'json' + + export_segment_rag_manifest( + report.get('manifest', []), + manifest_path, + format=export_format + ) + print(f"Manifest exported to: {manifest_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py new file mode 100644 index 0000000..6c152db --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py @@ -0,0 +1,1366 @@ +""" +AbletonMCP AI Server v2 - Servidor MCP robusto para generación musical +Integra FastMCP con Ableton Live 12 via socket TCP y Max for Live via UDP + +Para ejecutar: + python -m AbletonMCP_AI.MCP_Server.server_v2 + +O con uv: + uv run python -m AbletonMCP_AI.MCP_Server.server_v2 +""" + +from mcp.server.fastmcp import FastMCP, Context +import socket +import json +import logging +import sys +from dataclasses import dataclass +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any, List, Optional +from pathlib import Path +from datetime import datetime + +# Añadir el path para imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +try: +# from song_generator import SongGenerator, StyleConfig + from sample_index import SampleIndex +except ImportError as e: + print(f"Error importando módulos locales: {e}") + SongGenerator = None + SampleIndex = None + +# Configuración de logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler(Path(__file__).parent / 'server_v2.log', mode='a') + ] +) +logger = logging.getLogger("AbletonMCP-AI-v2") + +# ============================================================================ +# CONSTANTES Y CONFIGURACIÓN +# ============================================================================ + +DEFAULT_ABLETON_PORT = 9877 +DEFAULT_MAX_PORT = 9879 +MAX_HOST = "127.0.0.1" +ABLETON_HOST = "localhost" +SAMPLES_DIR = r"C:\Users\ren\embeddings\all_tracks" + +# Colores por tipo de track +TRACK_COLORS = { + 'kick': 10, # Rojo + 'snare': 20, # Verde + 'hat': 5, # Amarillo + 'clap': 45, # Naranja + 'bass': 30, # Azul + 'synth': 50, # Rosa/Magenta + 'chords': 60, # Púrpura + 'fx': 25, # Verde claro + 'vocal': 15, # Naranja oscuro +} + +# Instrucciones para el productor (contexto de IA) +PRODUCER_INSTRUCTIONS = """ +Eres AbletonMCP-AI v2, un productor musical experto integrado con Ableton Live 12 y Max for Live. +Tu objetivo es crear música electrónica profesional mediante prompts en lenguaje natural. + +CAPACIDADES PRINCIPALES: +1. Generar tracks completos con estructura profesional (Intro, Build, Drop, Break, Outro) +2. Crear patrones MIDI para diferentes géneros (Techno, House, Trance, Tech-House, etc.) +3. Seleccionar y cargar samples apropiados desde la librería local +4. Enviar rutas de samples a Max for Live para carga dinámica +5. Configurar BPM, tonalidad y estructura musical +6. Controlar transporte (play, stop, tempo) +7. Crear clips y escenas en Ableton + +HERRAMIENTAS DISPONIBLES: +- generate_song(genre, style, bpm): Genera una canción completa +- load_sample_kit(genre): Carga un kit de samples para un género +- create_pattern(instrument, pattern_type): Crea patrones MIDI +- control_transport(action): Controla reproducción +- get_session_info(): Obtiene información de la sesión + +ESTILOS SOPORTADOS: +- Techno: Industrial, Peak Time, Dub, Minimal, Acid +- House: Deep, Tech-House, Progressive, Afro, Classic 90s +- Trance: Psy, Progressive, Uplifting +- Drum & Bass: Liquid, Neuro, Jump-up, Jungle + +FLUJO DE TRABAJO: +1. Analizar el prompt del usuario para extraer género, BPM, tonalidad, mood +2. Detectar samples disponibles en la librería +3. Generar patrones MIDI característicos del género +4. Enviar comandos a Ableton via socket TCP +5. Enviar rutas de samples a Max via UDP +6. Proporcionar feedback sobre lo creado + +REGLAS: +- Siempre verifica la conexión con Ableton antes de ejecutar comandos +- Usa valores por defecto razonables si el usuario no especifica +- Organiza los tracks con colores consistentes +- Maneja errores gracefully y proporciona mensajes útiles +- Loggea todas las operaciones para debugging +""".strip() + + +# ============================================================================ +# CLASES DE CONEXIÓN +# ============================================================================ + +@dataclass +class AbletonConnection: + """Gestiona la conexión TCP con Ableton Live""" + host: str = ABLETON_HOST + port: int = DEFAULT_ABLETON_PORT + sock: Optional[socket.socket] = None + connected: bool = False + last_error: Optional[str] = None + + def connect(self, timeout: float = 5.0) -> bool: + """Conecta al Remote Script de Ableton""" + if self.connected and self.sock: + return True + + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(timeout) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(None) # Non-blocking después de conectar + self.connected = True + self.last_error = None + logger.info(f"Conectado a Ableton en {self.host}:{self.port}") + return True + except socket.timeout: + self.last_error = f"Timeout conectando a {self.host}:{self.port}" + logger.error(self.last_error) + self.sock = None + self.connected = False + return False + except Exception as e: + self.last_error = f"Error conectando a Ableton: {e}" + logger.error(self.last_error) + self.sock = None + self.connected = False + return False + + def disconnect(self): + """Desconecta de Ableton""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error desconectando: {e}") + finally: + self.sock = None + self.connected = False + logger.info("Desconectado de Ableton") + + def send_command(self, command_type: str, params: Dict[str, Any] = None, + timeout: float = 15.0) -> Dict[str, Any]: + """Envía un comando a Ableton y retorna la respuesta""" + if not self.connected and not self.connect(): + return {"status": "error", "message": "No conectado a Ableton"} + + command = { + "type": command_type, + "params": params or {} + } + + try: + logger.debug(f"Enviando comando: {command_type}") + self.sock.sendall(json.dumps(command).encode('utf-8')) + + # Recibir respuesta + self.sock.settimeout(timeout) + chunks = [] + + while True: + try: + chunk = self.sock.recv(8192) + if not chunk: + break + chunks.append(chunk) + + # Intentar parsear JSON completo + try: + data = b''.join(chunks) + response = json.loads(data.decode('utf-8')) + return response + except json.JSONDecodeError: + continue + + except socket.timeout: + logger.warning("Timeout esperando respuesta") + break + + # Respuesta incompleta + if chunks: + data = b''.join(chunks) + try: + return json.loads(data.decode('utf-8')) + except Exception: + return {"status": "error", "message": "Respuesta JSON incompleta"} + else: + return {"status": "error", "message": "No se recibió respuesta"} + + except socket.error as e: + self.connected = False + self.last_error = f"Error de socket: {e}" + logger.error(self.last_error) + return {"status": "error", "message": str(e)} + except Exception as e: + self.connected = False + self.last_error = f"Error en comunicación: {e}" + logger.error(self.last_error) + return {"status": "error", "message": str(e)} + + +@dataclass +class MaxConnection: + """Gestiona la conexión UDP con Max for Live""" + host: str = MAX_HOST + port: int = DEFAULT_MAX_PORT + sock: Optional[socket.socket] = None + + def __post_init__(self): + self._init_socket() + + def _init_socket(self): + """Inicializa el socket UDP""" + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + logger.info(f"Socket UDP inicializado para Max en {self.host}:{self.port}") + except Exception as e: + logger.error(f"Error inicializando socket UDP: {e}") + self.sock = None + + def send_message(self, message: Dict[str, Any]) -> bool: + """Envía un mensaje JSON a Max for Live via UDP""" + if not self.sock: + self._init_socket() + if not self.sock: + return False + + try: + data = json.dumps(message).encode('utf-8') + self.sock.sendto(data, (self.host, self.port)) + logger.debug(f"Mensaje enviado a Max: {message.get('type', 'unknown')}") + return True + except Exception as e: + logger.error(f"Error enviando mensaje a Max: {e}") + return False + + def send_sample_path(self, track_index: int, sample_path: str, + slot: int = 0) -> bool: + """Envía una ruta de sample a Max para cargar""" + message = { + "type": "load_sample", + "track_index": track_index, + "sample_path": sample_path, + "slot": slot, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + def send_sample_kit(self, kit: Dict[str, List[Dict]]) -> bool: + """Envía un kit completo de samples a Max""" + message = { + "type": "load_sample_kit", + "kit": kit, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + def send_command(self, command: str, params: Dict[str, Any] = None) -> bool: + """Envía un comando genérico a Max""" + message = { + "type": "command", + "command": command, + "params": params or {}, + "timestamp": datetime.now().isoformat() + } + return self.send_message(message) + + +# ============================================================================ +# GESTORES GLOBALES +# ============================================================================ + +_ableton_connection: Optional[AbletonConnection] = None +_max_connection: Optional[MaxConnection] = None +_sample_index: Optional['SampleIndex'] = None +_song_generator: Optional['SongGenerator'] = None + + +def get_ableton_connection() -> AbletonConnection: + """Obtiene o crea la conexión con Ableton""" + global _ableton_connection + if _ableton_connection is None: + _ableton_connection = AbletonConnection() + return _ableton_connection + + +def get_max_connection() -> MaxConnection: + """Obtiene o crea la conexión con Max""" + global _max_connection + if _max_connection is None: + _max_connection = MaxConnection() + return _max_connection + + +def get_sample_index() -> Optional['SampleIndex']: + """Obtiene o crea el índice de samples""" + global _sample_index + if _sample_index is None and SampleIndex is not None: + try: + _sample_index = SampleIndex(SAMPLES_DIR) + except Exception as e: + logger.error(f"Error cargando índice de samples: {e}") + return _sample_index + + +def get_song_generator() -> Optional['SongGenerator']: + """Obtiene o crea el generador de canciones""" + global _song_generator + if _song_generator is None and SongGenerator is not None: + _song_generator = SongGenerator() + return _song_generator + + +# ============================================================================ +# LIFESPAN DEL SERVIDOR +# ============================================================================ + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Maneja el ciclo de vida del servidor""" + try: + logger.info("=" * 60) + logger.info("AbletonMCP-AI Server v2 iniciando...") + logger.info("=" * 60) + + # Intentar conectar a Ableton + try: + ableton = get_ableton_connection() + if ableton.connect(): + logger.info("Conectado a Ableton Live") + else: + logger.warning("No se pudo conectar a Ableton (¿está abierto el script?)") + except Exception as e: + logger.warning(f"Error conectando a Ableton: {e}") + + # Inicializar conexión con Max + try: + get_max_connection() + logger.info(f"Conexión UDP con Max lista en puerto {DEFAULT_MAX_PORT}") + except Exception as e: + logger.warning(f"Error inicializando conexión con Max: {e}") + + # Inicializar índice de samples + try: + sample_index = get_sample_index() + if sample_index: + logger.info(f"Índice de samples cargado: {len(sample_index.samples)} samples") + else: + logger.warning("Índice de samples no disponible") + except Exception as e: + logger.warning(f"Error cargando índice de samples: {e}") + + # Inicializar generador de canciones + try: + song_gen = get_song_generator() + if song_gen: + logger.info("Generador de canciones listo") + else: + logger.warning("Generador de canciones no disponible") + except Exception as e: + logger.warning(f"Error inicializando generador: {e}") + + yield { + "ableton": _ableton_connection, + "max": _max_connection, + "samples": _sample_index, + "generator": _song_generator + } + + finally: + global _ableton_connection, _max_connection + if _ableton_connection: + logger.info("Desconectando de Ableton...") + _ableton_connection.disconnect() + if _max_connection and _max_connection.sock: + logger.info("Cerrando socket UDP...") + _max_connection.sock.close() + logger.info("AbletonMCP-AI Server v2 detenido") + + +# ============================================================================ +# CREAR SERVIDOR MCP +# ============================================================================ + +mcp = FastMCP( + "AbletonMCP-AI-v2", + instructions=PRODUCER_INSTRUCTIONS, + lifespan=server_lifespan +) + + +# ============================================================================ +# HERRAMIENTAS MCP - GENERACIÓN DE CANCIONES +# ============================================================================ + +@mcp.tool() +def generate_song( + ctx: Context, + genre: str = "house", + style: str = "", + bpm: float = 0, + key: str = "", + structure: str = "standard" +) -> str: + """ + Genera una canción completa con estructura profesional + + Args: + genre: Género musical (techno, house, trance, tech-house, drum-and-bass) + style: Sub-género o estilo específico (e.g., "industrial", "deep", "90s", "minimal") + bpm: BPM deseado (0 = auto-seleccionar según género) + key: Tonalidad (e.g., "Am", "F#m", "C") - vacío = auto-seleccionar + structure: Estructura del track (standard, minimal, extended) + + Returns: + Resumen de la canción generada + + Ejemplos: + generate_song("techno", "industrial", 138, "F#m") + generate_song("house", "deep", 124, "Am") + generate_song("tech-house", "groovy", 126) + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador de canciones no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return f"Error: No se pudo conectar a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" + + # Generar configuración + config = generator.generate_config(genre, style, bpm, key, structure) + + # Enviar comando a Ableton + response = ableton.send_command("generate_complete_song", { + "genre": genre, + "style": style or config.get('style', ''), + "bpm": config.get('bpm', 120), + "key": config.get('key', ''), + "structure": structure + }) + + if response.get("status") == "success": + summary = config.get("summary", "") + return f"Canción generada exitosamente!\n{summary}" + else: + return f"Error generando canción: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en generate_song") + return f"Error: {str(e)}" + + +@mcp.tool() +def load_sample_kit( + ctx: Context, + genre: str = "techno", + key: str = "", + bpm: int = 0 +) -> str: + """ + Carga un kit de samples completo para un género específico + + Args: + genre: Género musical para seleccionar samples apropiados + key: Tonalidad preferida para samples armónicos + bpm: BPM preferido para samples con tempo específico + + Returns: + Lista de samples cargados + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + max_conn = get_max_connection() + + # Obtener pack de samples + kit = sample_index.get_sample_pack(genre, key, bpm) + + # Contar samples encontrados + total_samples = sum(len(samples) for samples in kit.values()) + + if total_samples == 0: + return f"No se encontraron samples para el género '{genre}'" + + # Enviar a Max + if max_conn.send_sample_kit(kit): + # Construir resumen + lines = [f"Kit de samples para {genre} cargado:", ""] + for category, samples in kit.items(): + if samples: + lines.append(f"{category.upper()}:") + for s in samples[:2]: # Mostrar máximo 2 por categoría + lines.append(f" - {s['name']}") + if len(samples) > 2: + lines.append(f" ... y {len(samples)-2} más") + lines.append("") + lines.append(f"Total: {total_samples} samples enviados a Max") + return "\n".join(lines) + else: + return "Error enviando kit a Max for Live" + + except Exception as e: + logger.exception("Error en load_sample_kit") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_pattern( + ctx: Context, + instrument: str, + pattern_type: str = "standard", + track_index: int = -1, + clip_index: int = 0, + length: float = 4.0, + key: str = "Am", + genre: str = "techno" +) -> str: + """ + Crea un patrón MIDI para un instrumento específico + + Args: + instrument: Tipo de instrumento (kick, snare, hat, clap, bass, chords, lead, melody) + pattern_type: Tipo de patrón (standard, minimal, full, complex, simple) + track_index: Índice del track (-1 = crear nuevo) + clip_index: Índice del clip/slot + length: Duración en beats + key: Tonalidad para instrumentos melódicos + genre: Género para estilo del patrón + + Returns: + Confirmación del patrón creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + # Crear track si es necesario + if track_index < 0: + response = ableton.send_command("create_midi_track", {"index": -1}) + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + else: + return "Error creando track MIDI" + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Generar notas según instrumento + notes = [] + color = TRACK_COLORS.get(instrument.lower(), 0) + + if instrument.lower() in ['kick', 'bd', 'bass drum']: + notes = generator._create_kick_pattern(genre, pattern_type) + elif instrument.lower() in ['snare', 'sd', 'clap']: + notes = generator._create_clap_pattern(genre, pattern_type) + elif instrument.lower() in ['hat', 'hihat', 'hh']: + notes = generator._create_hat_pattern(genre, pattern_type) + elif instrument.lower() in ['perc', 'percussion']: + notes = generator._create_perc_pattern(genre, pattern_type) + elif instrument.lower() == 'bass': + notes = generator.create_bassline(key, pattern_type, length) + elif instrument.lower() in ['chords', 'chord', 'pads']: + notes = generator.create_chord_progression(key, genre, length) + elif instrument.lower() in ['lead', 'melody', 'synth']: + notes = generator.create_melody(key, 'minor', length, genre) + else: + return f"Instrumento '{instrument}' no reconocido" + + # Aplicar color al track + if color: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Patrón '{pattern_type}' para {instrument} creado en track {track_index}, clip {clip_index} ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_pattern") + return f"Error: {str(e)}" + + +@mcp.tool() +def control_transport( + ctx: Context, + action: str, + tempo: float = None +) -> str: + """ + Controla el transporte de Ableton (play, stop, tempo) + + Args: + action: Acción a ejecutar (play, stop, continue, toggle, set_tempo) + tempo: BPM a establecer (solo para action='set_tempo') + + Returns: + Confirmación de la acción + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + action = action.lower() + + if action == "play": + response = ableton.send_command("start_playback") + if response.get("status") == "success": + return "Reproducción iniciada" + elif action == "stop": + response = ableton.send_command("stop_playback") + if response.get("status") == "success": + return "Reproducción detenida" + elif action == "continue": + response = ableton.send_command("continue_playback") + if response.get("status") == "success": + return "Reproducción continuada" + elif action in ["set_tempo", "tempo", "bpm"]: + if tempo is None or tempo <= 0: + return "Error: Debes especificar un tempo válido" + response = ableton.send_command("set_tempo", {"tempo": tempo}) + if response.get("status") == "success": + return f"Tempo establecido a {tempo} BPM" + elif action == "get_tempo": + response = ableton.send_command("get_session_info") + if response.get("status") == "success": + return f"Tempo actual: {response.get('result', {}).get('tempo', 'desconocido')} BPM" + else: + return f"Acción '{action}' no reconocida. Usa: play, stop, continue, set_tempo" + + return f"Error: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en control_transport") + return f"Error: {str(e)}" + + +@mcp.tool() +def get_session_info(ctx: Context) -> str: + """ + Obtiene información completa de la sesión actual de Ableton + + Returns: + JSON con información de la sesión (tempo, tracks, estado de reproducción) + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return f"Error: No conectado a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" + + response = ableton.send_command("get_session_info") + + if response.get("status") == "success": + result = response.get("result", {}) + info_lines = [ + "Información de la sesión:", + f" Tempo: {result.get('tempo', 'N/A')} BPM", + f" Reproduciendo: {'Sí' if result.get('is_playing') else 'No'}", + f" Tracks: {result.get('num_tracks', 'N/A')}", + ] + if 'current_song_time' in result: + info_lines.append(f" Tiempo: {result.get('current_song_time')} beats") + return "\n".join(info_lines) + else: + return f"Error: {response.get('message', 'Error desconocido')}" + + except Exception as e: + logger.exception("Error en get_session_info") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - GESTIÓN DE SAMPLES +# ============================================================================ + +@mcp.tool() +def search_samples( + ctx: Context, + query: str, + category: str = "", + limit: int = 10 +) -> str: + """ + Busca samples en la librería local + + Args: + query: Término de búsqueda (e.g., "kick", "bass", "hat") + category: Categoría (kick, snare, hat, bass, synth, percussion, vocal) + limit: Número máximo de resultados + + Returns: + Lista de samples encontrados + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + results = sample_index.search(query, category, limit) + + if not results: + return f"No se encontraron samples para '{query}'" + + output = [f"Samples encontrados para '{query}':\n"] + for i, sample in enumerate(results, 1): + output.append(f"{i}. {sample['name']} ({sample['category']})") + output.append(f" Path: {sample['path']}") + if sample.get('key'): + output.append(f" Key: {sample['key']}, BPM: {sample.get('bpm', 'N/A')}") + output.append("") + + return "\n".join(output) + + except Exception as e: + logger.exception("Error en search_samples") + return f"Error: {str(e)}" + + +@mcp.tool() +def get_random_sample( + ctx: Context, + category: str = "" +) -> str: + """ + Obtiene un sample aleatorio de la librería + + Args: + category: Categoría opcional para filtrar + + Returns: + Información del sample seleccionado + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + sample = sample_index.get_random_sample(category) + + if not sample: + return f"No hay samples disponibles{' en categoría ' + category if category else ''}" + + return f"""Sample aleatorio seleccionado: +Nombre: {sample['name']} +Categoría: {sample['category']} +Path: {sample['path']} +Key: {sample.get('key', 'N/A')} +BPM: {sample.get('bpm', 'N/A')}""" + + except Exception as e: + logger.exception("Error en get_random_sample") + return f"Error: {str(e)}" + + +@mcp.tool() +def send_sample_to_max( + ctx: Context, + sample_path: str, + track_index: int = 0, + slot: int = 0 +) -> str: + """ + Envía una ruta de sample a Max for Live para cargar + + Args: + sample_path: Ruta completa del archivo de audio + track_index: Índice del track donde cargar + slot: Slot/clip donde cargar el sample + + Returns: + Confirmación del envío + """ + try: + max_conn = get_max_connection() + + if max_conn.send_sample_path(track_index, sample_path, slot): + return f"Sample enviado a Max: {Path(sample_path).name} -> Track {track_index}, Slot {slot}" + else: + return "Error enviando sample a Max" + + except Exception as e: + logger.exception("Error en send_sample_to_max") + return f"Error: {str(e)}" + + +@mcp.tool() +def refresh_sample_index(ctx: Context) -> str: + """ + Refresca el índice de samples escaneando el directorio nuevamente + + Returns: + Confirmación con el número de samples encontrados + """ + try: + global _sample_index + if SampleIndex is None: + return "Error: Módulo SampleIndex no disponible" + + _sample_index = SampleIndex(SAMPLES_DIR) + _sample_index.refresh() + + return f"Índice refrescado: {len(_sample_index.samples)} samples encontrados" + + except Exception as e: + logger.exception("Error en refresh_sample_index") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - CREACIÓN AVANZADA +# ============================================================================ + +@mcp.tool() +def create_drum_pattern( + ctx: Context, + track_index: int, + clip_index: int, + style: str = "techno", + pattern_type: str = "full", + length: float = 4.0 +) -> str: + """ + Crea un patrón de batería completo + + Args: + track_index: Índice del track MIDI donde crear el patrón + clip_index: Índice del clip/slot + style: Estilo (techno, house, trance, minimal) + pattern_type: Tipo de patrón (full, kick-only, hats-only, minimal) + length: Duración en beats + + Returns: + Confirmación del patrón creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_drum_pattern(style, pattern_type, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Patrón de batería '{style}' creado ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_drum_pattern") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_bassline( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + style: str = "rolling", + length: float = 16.0 +) -> str: + """ + Crea una línea de bajo musical + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + style: Estilo (rolling, minimal, acid, walking, offbeat) + length: Duración en beats + + Returns: + Confirmación del bassline creado + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_bassline(key, style, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Bassline '{style}' en {key} creado ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_bassline") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_chord_progression( + ctx: Context, + track_index: int, + clip_index: int, + key: str, + progression_type: str = "techno", + length: float = 16.0 +) -> str: + """ + Crea una progresión de acordes + + Args: + track_index: Índice del track MIDI + clip_index: Índice del clip + key: Tonalidad (e.g., "Am", "F#m", "C") + progression_type: Tipo (techno, house, deep, minor) + length: Duración en beats (usualmente 16 = 4 compases) + + Returns: + Confirmación de la progresión creada + """ + try: + generator = get_song_generator() + if not generator: + return "Error: Generador no disponible" + + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + notes = generator.create_chord_progression(key, progression_type, length) + + # Crear clip + clip_response = ableton.send_command("create_clip", { + "track_index": track_index, + "clip_index": clip_index, + "length": length + }) + + if clip_response.get("status") != "success": + return f"Error creando clip: {clip_response.get('message')}" + + # Agregar notas + notes_response = ableton.send_command("add_notes_to_clip", { + "track_index": track_index, + "clip_index": clip_index, + "notes": notes + }) + + if notes_response.get("status") == "success": + return f"Progresión '{progression_type}' en {key} creada ({len(notes)} notas)" + else: + return f"Error agregando notas: {notes_response.get('message')}" + + except Exception as e: + logger.exception("Error en create_chord_progression") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - GESTIÓN DE TRACKS Y CLIPS +# ============================================================================ + +@mcp.tool() +def create_midi_track( + ctx: Context, + name: str = "MIDI Track", + color: int = None +) -> str: + """ + Crea un nuevo track MIDI + + Args: + name: Nombre del track + color: Color del track (0-69, opcional) + + Returns: + Confirmación con el índice del track creado + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("create_midi_track", {"index": -1}) + + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + + # Setear nombre + ableton.send_command("set_track_name", { + "track_index": track_index, + "name": name + }) + + # Setear color si se especificó + if color is not None: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + return f"Track MIDI '{name}' creado en índice {track_index}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en create_midi_track") + return f"Error: {str(e)}" + + +@mcp.tool() +def create_audio_track( + ctx: Context, + name: str = "Audio Track", + color: int = None +) -> str: + """ + Crea un nuevo track de audio + + Args: + name: Nombre del track + color: Color del track (0-69, opcional) + + Returns: + Confirmación con el índice del track creado + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("create_audio_track", {"index": -1}) + + if response.get("status") == "success": + track_index = response.get("result", {}).get("index", 0) + + # Setear nombre + ableton.send_command("set_track_name", { + "track_index": track_index, + "name": name + }) + + # Setear color si se especificó + if color is not None: + ableton.send_command("set_track_color", { + "track_index": track_index, + "color": color + }) + + return f"Track de audio '{name}' creado en índice {track_index}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en create_audio_track") + return f"Error: {str(e)}" + + +@mcp.tool() +def set_track_volume( + ctx: Context, + track_index: int, + volume: float +) -> str: + """ + Ajusta el volumen de un track (0.0 - 1.0) + + Args: + track_index: Índice del track + volume: Volumen entre 0.0 y 1.0 + + Returns: + Confirmación del cambio + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("set_track_volume", { + "track_index": track_index, + "volume": volume + }) + + if response.get("status") == "success": + return f"Volumen del track {track_index} ajustado a {volume:.2f}" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en set_track_volume") + return f"Error: {str(e)}" + + +@mcp.tool() +def fire_clip( + ctx: Context, + track_index: int, + clip_index: int +) -> str: + """ + Dispara/reproduce un clip específico + + Args: + track_index: Índice del track + clip_index: Índice del clip/slot + + Returns: + Confirmación + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("fire_clip", { + "track_index": track_index, + "clip_index": clip_index + }) + + if response.get("status") == "success": + return f"Clip en track {track_index}, slot {clip_index} disparado" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en fire_clip") + return f"Error: {str(e)}" + + +@mcp.tool() +def fire_scene( + ctx: Context, + scene_index: int +) -> str: + """ + Dispara una scene (todos sus clips) + + Args: + scene_index: Índice de la scene + + Returns: + Confirmación + """ + try: + ableton = get_ableton_connection() + if not ableton.connect(): + return "Error: No conectado a Ableton" + + response = ableton.send_command("fire_scene", { + "scene_index": scene_index + }) + + if response.get("status") == "success": + return f"Scene {scene_index} disparada" + else: + return f"Error: {response.get('message')}" + + except Exception as e: + logger.exception("Error en fire_scene") + return f"Error: {str(e)}" + + +# ============================================================================ +# HERRAMIENTAS MCP - UTILIDADES +# ============================================================================ + +@mcp.tool() +def get_available_samples(ctx: Context) -> str: + """ + Obtiene un resumen de los samples disponibles en la librería + + Returns: + Resumen por categorías + """ + try: + sample_index = get_sample_index() + if not sample_index: + return "Error: Índice de samples no disponible" + + categories = {} + for sample in sample_index.samples: + cat = sample['category'] + categories[cat] = categories.get(cat, 0) + 1 + + lines = ["Samples disponibles:", ""] + for cat, count in sorted(categories.items(), key=lambda x: -x[1]): + lines.append(f" {cat}: {count}") + lines.append("") + lines.append(f"Total: {len(sample_index.samples)} samples") + + return "\n".join(lines) + + except Exception as e: + logger.exception("Error en get_available_samples") + return f"Error: {str(e)}" + + +@mcp.tool() +def test_connections(ctx: Context) -> str: + """ + Prueba las conexiones con Ableton y Max + + Returns: + Estado de las conexiones + """ + results = [] + + # Probar Ableton + try: + ableton = get_ableton_connection() + if ableton.connect(timeout=3.0): + results.append("Ableton: Conectado") + # Probar comando simple + resp = ableton.send_command("get_session_info") + if resp.get("status") == "success": + results.append(f" - Tempo: {resp.get('result', {}).get('tempo')} BPM") + results.append(f" - Tracks: {resp.get('result', {}).get('num_tracks')}") + else: + results.append(f"Ableton: No conectado ({ableton.last_error})") + except Exception as e: + results.append(f"Ableton: Error - {e}") + + # Probar Max + try: + max_conn = get_max_connection() + if max_conn.send_message({"type": "ping", "timestamp": datetime.now().isoformat()}): + results.append(f"Max for Live: Conexión UDP lista en puerto {DEFAULT_MAX_PORT}") + else: + results.append("Max for Live: Error enviando mensaje") + except Exception as e: + results.append(f"Max for Live: Error - {e}") + + # Probar Samples + try: + sample_index = get_sample_index() + if sample_index: + results.append(f"Samples: {len(sample_index.samples)} samples indexados") + else: + results.append("Samples: Índice no disponible") + except Exception as e: + results.append(f"Samples: Error - {e}") + + return "\n".join(results) + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + """Punto de entrada principal""" + import argparse + + parser = argparse.ArgumentParser(description="AbletonMCP-AI Server v2") + parser.add_argument("--port", type=int, default=0, help="Puerto para el servidor MCP (0 = auto)") + parser.add_argument("--transport", type=str, default="stdio", + choices=["stdio", "sse"], help="Transporte MCP") + parser.add_argument("--test", action="store_true", help="Probar conexiones y salir") + args = parser.parse_args() + + print("=" * 60) + print("AbletonMCP-AI Server v2") + print("=" * 60) + print(f"Transporte: {args.transport}") + print(f"Ableton: {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}") + print(f"Max UDP: {MAX_HOST}:{DEFAULT_MAX_PORT}") + print(f"Samples: {SAMPLES_DIR}") + print("-" * 60) + + if args.test: + print("\nProbando conexiones...") + # Crear contexto temporal para test + ctx = Context(request_context={}) + result = test_connections(ctx) + print(result) + return + + # Iniciar servidor MCP + mcp.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py new file mode 100644 index 0000000..df16288 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py @@ -0,0 +1,798 @@ +import argparse +import json +import socket +from datetime import datetime +from typing import Any, Dict, List, Tuple + +try: + from song_generator import SongGenerator +except ImportError: + SongGenerator = None + + +STRUCTURE_SCENE_COUNTS = { + "minimal": 4, + "standard": 6, + "extended": 7, +} + +# Expected buses for Phase 7 validation +EXPECTED_BUSES = ["drums", "bass", "music", "vocal", "fx"] + +EXPECTED_CRITICAL_ROLES = {"kick", "bass", "clap", "hat"} + +EXPECTED_AUDIO_FX_LAYERS = ["AUDIO ATMOS", "AUDIO CRASH FX", "AUDIO TRANSITION FILL"] + +EXPECTED_BUS_NAMES = ["DRUMS", "BASS", "MUSIC"] + +MIN_TRACKS_FOR_EXPORT = 6 +MIN_BUSES_FOR_EXPORT = 3 +MIN_RETURNS_FOR_EXPORT = 2 +MASTER_VOLUME_RANGE = (0.75, 0.95) + +# Expected AUDIO RESAMPLE track names +AUDIO_RESAMPLE_TRACKS = [ + "AUDIO RESAMPLE REVERSE FX", + "AUDIO RESAMPLE RISER", + "AUDIO RESAMPLE DOWNLIFTER", + "AUDIO RESAMPLE STUTTER", +] + +# Bus routing map: track role -> expected bus output +BUS_ROUTING_MAP = { + "kick": {"drums"}, + "snare": {"drums"}, + "clap": {"drums"}, + "hat": {"drums"}, + "perc": {"drums"}, + "sub_bass": {"bass"}, + "bass": {"bass"}, + "chords": {"music"}, + "pad": {"music"}, + "pluck": {"music"}, + "lead": {"music"}, + "vocal": {"vocal"}, + "vocal_chop": {"vocal"}, + "reverse_fx": {"fx"}, + "riser": {"fx"}, + "impact": {"fx"}, + "atmos": {"fx"}, + "crash": {"drums", "fx"}, +} + + +def _extract_bus_payload(payload: Any) -> List[Dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + buses = payload.get("buses", []) + if isinstance(buses, list): + return [item for item in buses if isinstance(item, dict)] + return [] + + +def _normalize_bus_key(name: str) -> str: + normalized = "".join(ch for ch in (name or "").lower() if ch.isalnum()) + if not normalized: + return "" + if "drum" in normalized or "groove" in normalized: + return "drums" + if "bass" in normalized or "tube" in normalized or "subdeep" in normalized: + return "bass" + if "music" in normalized or "wide" in normalized: + return "music" + if "vocal" in normalized or "vox" in normalized or "tail" in normalized: + return "vocal" + if "fx" in normalized or "wash" in normalized: + return "fx" + return "" + + +def _canonical_track_name(name: str) -> str: + text = (name or "").strip().lower() + if not text: + return "" + if " (" in text: + text = text.split(" (", 1)[0].strip() + return text + + +class AbletonSocketClient: + def __init__(self, host: str = "127.0.0.1", port: int = 9877, timeout: float = 15.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + raise RuntimeError(f"No response for command: {command_type}") + + return json.loads(line) + + +def expect_success(name: str, response: Dict[str, Any]) -> Dict[str, Any]: + if response.get("status") != "success": + raise RuntimeError(f"{name} failed: {response}") + return response.get("result", {}) + + +class TestResult: + """Tracks test results for reporting.""" + def __init__(self): + self.passed: List[Tuple[str, str]] = [] + self.failed: List[Tuple[str, str]] = [] + self.skipped: List[Tuple[str, str]] = [] + self.warnings: List[Tuple[str, str]] = [] + + def add_pass(self, name: str, details: str = ""): + self.passed.append((name, details)) + + def add_fail(self, name: str, error: str): + self.failed.append((name, error)) + + def add_skip(self, name: str, reason: str): + self.skipped.append((name, reason)) + + def add_warning(self, name: str, message: str): + self.warnings.append((name, message)) + + def to_dict(self) -> Dict[str, Any]: + return { + "summary": { + "total": len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings), + "passed": len(self.passed), + "failed": len(self.failed), + "skipped": len(self.skipped), + "warnings": len(self.warnings), + "status": "PASS" if len(self.failed) == 0 else "FAIL", + }, + "passed_tests": [{"name": n, "details": d} for n, d in self.passed], + "failed_tests": [{"name": n, "error": d} for n, d in self.failed], + "skipped_tests": [{"name": n, "reason": d} for n, d in self.skipped], + "warnings": [{"name": n, "message": d} for n, d in self.warnings], + } + + def print_report(self): + print("\n" + "=" * 60) + print("PHASE 7 SMOKE TEST REPORT") + print("=" * 60) + print(f"Timestamp: {datetime.now().isoformat()}") + print(f"Total: {len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings)}") + print(f"Passed: {len(self.passed)}") + print(f"Failed: {len(self.failed)}") + print(f"Skipped: {len(self.skipped)}") + print(f"Warnings: {len(self.warnings)}") + print("-" * 60) + + if self.passed: + print("\n[PASSED]") + for name, details in self.passed: + print(f" [OK] {name}: {details}") + + if self.failed: + print("\n[FAILED]") + for name, error in self.failed: + print(f" [FAIL] {name}: {error}") + + if self.warnings: + print("\n[WARNINGS]") + for name, message in self.warnings: + print(f" [WARN] {name}: {message}") + + if self.skipped: + print("\n[SKIPPED]") + for name, reason in self.skipped: + print(f" [SKIP] {name}: {reason}") + + print("\n" + "=" * 60) + status = "PASS" if len(self.failed) == 0 else "FAIL" + print(f"FINAL STATUS: {status}") + print("=" * 60 + "\n") + + +def run_readonly_checks(client: AbletonSocketClient) -> List[Tuple[str, str]]: + checks = [] + + expect_success("get_session_info", client.send("get_session_info")) + checks.append(( + "get_session_info", +# f"tempo={session.get('tempo')} tracks={session.get('num_tracks')} scenes={session.get('num_scenes')}", + )) + + tracks = expect_success("get_tracks", client.send("get_tracks")) + checks.append(("get_tracks", f"tracks={len(tracks)}")) + + return checks + + +def run_generation_check( + client: AbletonSocketClient, + genre: str, + style: str, + bpm: float, + key: str, + structure: str, + use_blueprint: bool = False, +) -> List[Tuple[str, str]]: + checks = [] + params = { + "genre": genre, + "style": style, + "bpm": bpm, + "key": key, + "structure": structure, + } + + if use_blueprint and SongGenerator is not None: + params = SongGenerator().generate_config(genre, style, bpm, key, structure) + + result = expect_success( + "generate_complete_song", + client.send("generate_complete_song", params), + ) + checks.append(( + "generate_complete_song", + f"tracks={result.get('tracks')} scenes={result.get('scenes')} structure={result.get('structure')}", + )) + + session = expect_success("post_generate_session_info", client.send("get_session_info")) + actual_scenes = session.get("num_scenes") + expected_scenes = len(params.get("sections", [])) if use_blueprint and isinstance(params, dict) and params.get("sections") else STRUCTURE_SCENE_COUNTS.get(structure.lower()) + if expected_scenes is not None and actual_scenes != expected_scenes: + raise RuntimeError( + f"scene count mismatch after generate_complete_song: expected {expected_scenes}, got {actual_scenes}" + ) + + checks.append(( + "post_generate_session_info", + f"tracks={session.get('num_tracks')} scenes={actual_scenes}", + )) + + return checks + + +def run_bus_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify buses are created correctly.""" + try: + buses_payload = expect_success("list_buses", client.send("list_buses")) + buses = _extract_bus_payload(buses_payload) + bus_keys = {_normalize_bus_key(bus.get("name", "")) for bus in buses} + bus_keys.discard("") + + found_buses = [] + missing_buses = [] + for expected in EXPECTED_BUSES: + if expected in bus_keys: + found_buses.append(expected) + else: + missing_buses.append(expected) + + if found_buses: + results.add_pass("buses_found", f"found={found_buses}") + + if missing_buses: + # Not a failure if buses don't exist yet - they may be created during generation + results.add_skip("buses_missing", f"not_found={missing_buses} (may be created during generation)") + else: + results.add_pass("buses_complete", "all expected buses present") + + except Exception as e: + results.add_fail("buses_check", str(e)) + + +def run_routing_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify track routing is configured correctly.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + if not tracks: + results.add_skip("routing_check", "no tracks to verify routing") + return + + correct_routing = 0 + incorrect_routing = [] + no_routing = 0 + + for track in tracks: + original_track_name = track.get("name", "") + track_name = _canonical_track_name(original_track_name) + output_routing = track.get("current_output_routing", "") + output_bus_key = _normalize_bus_key(output_routing) + track_bus_key = _normalize_bus_key(track_name) + + if output_routing and output_routing.lower() != "master": + correct_routing += 1 + elif not output_routing: + no_routing += 1 + + if track_bus_key: + continue + + for role, expected_bus in BUS_ROUTING_MAP.items(): + if role in track_name: + if output_bus_key in expected_bus: + correct_routing += 1 + elif output_routing.lower() != "master": + expected_label = "/".join(sorted(expected_bus)) + incorrect_routing.append(f"{original_track_name.lower()} -> {output_routing} (expected {expected_label})") + + results.add_pass("routing_summary", f"correct={correct_routing} no_routing={no_routing}") + + if incorrect_routing: + results.add_fail("routing_mismatches", ", ".join(incorrect_routing[:5])) + elif correct_routing > 0: + results.add_pass("routing_correct", f"{correct_routing} tracks with non-master routing") + + except Exception as e: + results.add_fail("routing_check", str(e)) + + +def run_audio_resample_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify AUDIO RESAMPLE tracks exist.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [t.get("name", "") for t in tracks] + + found_layers = [] + missing_layers = [] + + for expected in AUDIO_RESAMPLE_TRACKS: + if any(expected.upper() in name.upper() for name in track_names): + found_layers.append(expected) + else: + missing_layers.append(expected) + + if found_layers: + results.add_pass("audio_resample_found", f"layers={found_layers}") + + if missing_layers: + results.add_skip("audio_resample_missing", f"not_found={missing_layers} (may require reference audio)") + else: + results.add_pass("audio_resample_complete", "all 4 resample layers present") + + # Verify they are audio tracks + for track in tracks: + name = track.get("name", "").upper() + if "AUDIO RESAMPLE" in name: + if track.get("has_audio_input"): + results.add_pass(f"audio_track_type_{name[:20]}", "correct audio track type") + else: + results.add_fail(f"audio_track_type_{name[:20]}", "expected audio track") + + except Exception as e: + results.add_fail("audio_resample_check", str(e)) + + +def run_automation_snapshot_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify automation and device parameter snapshots.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + total_devices = 0 + tracks_with_devices = 0 + tracks_with_automation = 0 + + for track in tracks: + num_devices = track.get("num_devices", 0) + if num_devices > 0: + total_devices += num_devices + tracks_with_devices += 1 + + # Check for arrangement clips (may contain automation) + arrangement_clips = track.get("arrangement_clip_count", 0) + if arrangement_clips > 0: + tracks_with_automation += 1 + + if tracks_with_devices > 0: + results.add_pass("automation_devices", f"tracks_with_devices={tracks_with_devices} total_devices={total_devices}") + else: + results.add_skip("automation_devices", "no devices found") + + if tracks_with_automation > 0: + results.add_pass("automation_clips", f"tracks_with_arrangement_clips={tracks_with_automation}") + else: + results.add_skip("automation_clips", "no arrangement clips (may need to commit to arrangement)") + + # Try to get device parameters for first track with devices + for i, track in enumerate(tracks): + if track.get("num_devices", 0) > 0: + try: + devices = expect_success("get_devices", client.send("get_devices", {"track_index": i})) + if devices: + params_sample = [] + for dev in devices[:3]: + params = dev.get("parameters", []) + if params: + params_sample.append(f"{dev.get('name', '?')}:{len(params)}params") + if params_sample: + results.add_pass("automation_params_snapshot", ", ".join(params_sample[:3])) + break + except Exception: + pass + break + + except Exception as e: + results.add_fail("automation_snapshot_check", str(e)) + + +def run_loudness_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify basic loudness levels using output meters.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + tracks_with_signal = 0 + max_level = 0.0 + level_samples = [] + + for track in tracks: + output_level = track.get("output_meter_level", 0.0) + left = track.get("output_meter_left", 0.0) + right = track.get("output_meter_right", 0.0) + + if output_level and output_level > 0: + tracks_with_signal += 1 + max_level = max(max_level, output_level) + level_samples.append(f"{track.get('name', '?')[:15]}:{output_level:.2f}") + + # Check for stereo balance + if left and right and left > 0 and right > 0: + balance = abs(left - right) + if balance < 0.1: + pass # Balanced stereo + + if tracks_with_signal > 0: + results.add_pass("loudness_signal_detected", f"tracks_with_signal={tracks_with_signal} max_level={max_level:.3f}") + else: + results.add_skip("loudness_signal", "no signal detected (playback may be stopped)") + + # Check for clipping (levels > 1.0) + if max_level > 1.0: + results.add_fail("loudness_clipping", f"max_level={max_level:.3f} indicates potential clipping") + else: + results.add_pass("loudness_no_clipping", f"max_level={max_level:.3f}") + + # Sample levels for verification + if level_samples: + results.add_pass("loudness_levels", ", ".join(level_samples[:5])) + + except Exception as e: + results.add_fail("loudness_check", str(e)) + + +def run_critical_layer_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify critical layers (kick, bass, clap, hat) exist and have content.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] + + found_layers = {role: False for role in EXPECTED_CRITICAL_ROLES} + for track_name in track_names: + for role in EXPECTED_CRITICAL_ROLES: + if role.upper() in track_name or f"AUDIO {role.upper()}" in track_name: + found_layers[role] = True + break + + for role, found in found_layers.items(): + if found: + results.add_pass(f"critical_layer_{role}", "found in tracks") + else: + results.add_fail(f"critical_layer_{role}", "missing - set may sound incomplete") + except Exception as e: + results.add_fail("critical_layer_check", str(e)) + + +def run_derived_fx_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify derived FX tracks (AUDIO RESAMPLE) are present.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] + + found_derived = [] + missing_derived = [] + for expected in AUDIO_RESAMPLE_TRACKS: + if any(expected.upper() in name for name in track_names): + found_derived.append(expected) + else: + missing_derived.append(expected) + + if found_derived: + results.add_pass("derived_fx_found", f"layers={found_derived}") + + if missing_derived: + results.add_skip("derived_fx_missing", f"not_found={missing_derived} (may require reference audio)") + else: + results.add_pass("derived_fx_complete", "all 4 resample layers present") + + except Exception as e: + results.add_fail("derived_fx_check", str(e)) + + +def run_export_readiness_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify set is ready for export.""" + try: + expect_success("get_session_info", client.send("get_session_info")) + tracks = expect_success("get_tracks", client.send("get_tracks")) + + issues = [] + + track_count = len(tracks) if isinstance(tracks, list) else 0 + if track_count < MIN_TRACKS_FOR_EXPORT: + issues.append(f"insufficient_tracks: {track_count} (need {MIN_TRACKS_FOR_EXPORT}+)") + + master_response = client.send("get_track_info", {"track_type": "master", "track_index": 0}) + if master_response.get("status") == "success": + master_volume = float(master_response.get("result", {}).get("volume", 0.85)) + if master_volume < MASTER_VOLUME_RANGE[0]: + issues.append(f"master_volume_low: {master_volume:.2f}") + elif master_volume > MASTER_VOLUME_RANGE[1]: + issues.append(f"master_volume_high: {master_volume:.2f}") + + muted_count = sum(1 for t in tracks if isinstance(t, dict) and t.get("mute", False)) + if muted_count > track_count * 0.5: + issues.append(f"too_many_muted: {muted_count}/{track_count}") + + if issues: + results.add_pass("export_readiness_issues", f"issues={len(issues)}") + for issue in issues: + results.add_fail(f"export_ready_{issue.split(':')[0]}", issue) + else: + results.add_pass("export_ready", "set appears ready for export") + + except Exception as e: + results.add_fail("export_readiness_check", str(e)) + + +def run_midi_clip_content_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify MIDI tracks have clips with notes.""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + midi_tracks_empty = [] + midi_tracks_with_notes = 0 + + for track in tracks: + if not isinstance(track, dict): + continue + track_type = str(track.get("type", "")).lower() + if track_type != "midi": + continue + + track_name = track.get("name", "?") + clips = track.get("clips", []) + if not isinstance(clips, list): + clips = [] + + has_notes = False + empty_clips = [] + for clip in clips: + if not isinstance(clip, dict): + continue + notes_count = clip.get("notes_count", 0) + has_notes_flag = clip.get("has_notes", None) + if has_notes_flag is True or notes_count > 0: + has_notes = True + elif has_notes_flag is False or (has_notes_flag is None and notes_count == 0): + empty_clips.append(clip.get("name", "?")) + if has_notes: + midi_tracks_with_notes += 1 + elif empty_clips: + midi_tracks_empty.append({ + "track_name": track_name, + "empty_clips_count": len(empty_clips), + }) + + if midi_tracks_with_notes > 0: + results.add_pass("midi_tracks_with_notes", f"count={midi_tracks_with_notes}") + + if midi_tracks_empty: + for track_info in midi_tracks_empty[:3]: + results.add_fail( + f"midi_track_empty_{track_info['track_name'][:20]}", + f"Track has {track_info['empty_clips_count']} empty MIDI clips - may need notes" + ) + + except Exception as e: + results.add_fail("midi_clip_content_check", str(e)) + + +def run_bus_signal_checks(client: AbletonSocketClient, results: TestResult) -> None: + """Verify buses receive signal from tracks.""" + try: + buses_payload = expect_success("list_buses", client.send("list_buses")) + buses = _extract_bus_payload(buses_payload) + tracks = expect_success("get_tracks", client.send("get_tracks")) + + bus_signal_map = {} + for bus in buses: + if not isinstance(bus, dict): + continue + bus_name = bus.get("name", "").upper() + bus_signal_map[bus_name] = {"senders": [], "has_signal": False} + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = str(track.get("name", "")).upper() + output_routing = str(track.get("current_output_routing", "")).upper() + + for bus_name in bus_signal_map: + if bus_name in output_routing: + bus_signal_map[bus_name]["senders"].append(track_name) + + sends = track.get("sends", []) + if isinstance(sends, list): + for send_level in sends: + try: + if float(send_level) > 0.01: + pass + except (TypeError, ValueError): + pass + + buses_without_senders = [] + buses_with_senders = [] + + for bus_name, info in bus_signal_map.items(): + if info["senders"]: + buses_with_senders.append(bus_name) + else: + buses_without_senders.append(bus_name) + + if buses_with_senders: + results.add_pass("buses_with_signal", f"buses={buses_with_senders}") + + if buses_without_senders: + for bus_name in buses_without_senders[:3]: + results.add_fail(f"bus_no_signal_{bus_name[:15]}", + f"Bus '{bus_name}' has no routed tracks - will not produce output") + + except Exception as e: + results.add_fail("bus_signal_check", str(e)) + + +def run_clipping_detection(client: AbletonSocketClient, results: TestResult) -> None: + """Detect tracks with dangerously high volume (clipping risk).""" + try: + tracks = expect_success("get_tracks", client.send("get_tracks")) + + clipping_tracks = [] + high_volume_tracks = [] + + for track in tracks: + if not isinstance(track, dict): + continue + track_name = track.get("name", "?") + volume = float(track.get("volume", 0.85)) + + if volume > 0.95: + clipping_tracks.append({"name": track_name, "volume": volume}) + elif volume > 0.90: + high_volume_tracks.append({"name": track_name, "volume": volume}) + + if clipping_tracks: + for track_info in clipping_tracks[:3]: + results.add_fail(f"clipping_track_{track_info['name'][:15]}",f"Volume {track_info['volume']:.2f} > 0.95 - CLIPPING RISK") + + if high_volume_tracks: + for track_info in high_volume_tracks[:3]: + results.add_warning(f"high_volume_{track_info['name'][:15]}", + f"Volume {track_info['volume']:.2f} - consider reducing") + + if not clipping_tracks and not high_volume_tracks: + results.add_pass("no_clipping_tracks", "All track volumes in safe range") + + except Exception as e: + results.add_fail("clipping_detection", str(e)) + + +def run_all_phase7_tests(client: AbletonSocketClient, results: TestResult) -> None: + """Run all Phase 7 smoke tests.""" + print("\n[Phase 7] Running bus verification...") + run_bus_checks(client, results) + + print("[Phase 7] Running routing verification...") + run_routing_checks(client, results) + + print("[Phase 7] Running AUDIO RESAMPLE track verification...") + run_audio_resample_checks(client, results) + + print("[Phase 7] Running automation snapshot verification...") + run_automation_snapshot_checks(client, results) + + print("[Phase 7] Running loudness verification...") + run_loudness_checks(client, results) + + print("[Phase 7] Running critical layer verification...") + run_critical_layer_checks(client, results) + + print("[Phase 7] Running derived FX verification...") + run_derived_fx_checks(client, results) + + print("[Phase 7] Running export readiness verification...") + run_export_readiness_checks(client, results) + + print("[Phase 7] Running MIDI clip content verification...") + run_midi_clip_content_checks(client, results) + + print("[Phase 7] Running bus signal verification...") + run_bus_signal_checks(client, results) + + print("[Phase 7] Running clipping detection...") + run_clipping_detection(client, results) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Smoke test for AbletonMCP_AI socket runtime") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9877) + parser.add_argument("--timeout", type=float, default=15.0) + parser.add_argument("--generate-demo", action="store_true") + parser.add_argument("--genre", default="techno") + parser.add_argument("--style", default="industrial") + parser.add_argument("--bpm", type=float, default=128.0) + parser.add_argument("--key", default="Am") + parser.add_argument("--structure", default="standard") + parser.add_argument("--use-blueprint", action="store_true") + parser.add_argument("--phase7", action="store_true", help="Run Phase 7 extended tests (buses, routing, audio resample, automation, loudness)") + parser.add_argument("--json-report", action="store_true", help="Output report as JSON") + args = parser.parse_args() + + client = AbletonSocketClient(host=args.host, port=args.port, timeout=args.timeout) + + # Run basic checks + print("[Basic] Running readonly checks...") + checks = run_readonly_checks(client) + + for name, details in checks: + print(f"[ok] {name}: {details}") + + # Run generation check if requested + if args.generate_demo: + print("\n[Generation] Running generation check...") + checks.extend( + run_generation_check( + client, + genre=args.genre, + style=args.style, + bpm=args.bpm, + key=args.key, + structure=args.structure, + use_blueprint=args.use_blueprint, + ) + ) + for name, details in checks[-2:]: + print(f"[ok] {name}: {details}") + + # Run Phase 7 tests if requested + results = TestResult() + if args.phase7: + run_all_phase7_tests(client, results) + + if args.json_report: + print(json.dumps(results.to_dict(), indent=2)) + else: + results.print_report() + + return 0 if len(results.failed) == 0 else 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py new file mode 100644 index 0000000..141f1fc --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py @@ -0,0 +1,6268 @@ +""" +song_generator.py - Generador musical para AbletonMCP-AI. +""" + +import random +import logging +from typing import List, Dict, Any, Optional, Union, Tuple +from dataclasses import dataclass +from pathlib import Path +from collections import defaultdict + +logger = logging.getLogger("SongGenerator") + +# Notas MIDI para referencia +NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + +# Escalas comunes (semitonos desde la raíz) +SCALES = { + 'major': [0, 2, 4, 5, 7, 9, 11], + 'minor': [0, 2, 3, 5, 7, 8, 10], + 'harmonic_minor': [0, 2, 3, 5, 7, 8, 11], + 'dorian': [0, 2, 3, 5, 7, 9, 10], + 'phrygian': [0, 1, 3, 5, 7, 8, 10], + 'mixolydian': [0, 2, 4, 5, 7, 9, 10], + 'pentatonic_minor': [0, 3, 5, 7, 10], + 'pentatonic_major': [0, 2, 4, 7, 9], + 'blues': [0, 3, 5, 6, 7, 10], +} + +# Progresiones de acordes comunes +CHORD_PROGRESSIONS = { + 'techno': [ + [1, 1, 1, 1], # i - i - i - i (minimal) + [1, 6, 1, 6], # i - VI - i - VI + [1, 4, 1, 4], # i - iv - i - iv + [1, 7, 6, 7], # i - VII - VI - VII + ], + 'house': [ + [1, 5, 6, 4], # I - V - vi - IV (pop house) + [1, 4, 5, 1], # I - IV - V - I + [6, 4, 1, 5], # vi - IV - I - V + [1, 6, 4, 5], # I - vi - IV - V + ], + 'deep': [ + [1, 6, 2, 5], # i - VI - ii - V + [2, 5, 1, 6], # ii - V - i - VI + ], + 'tech-house': [ + [1, 6, 3, 6], # i - VI - III - VI (dark, hypnotic: Am -> Fm -> Cm -> Fm) + [1, 5, 1, 5], # i - v - i - v (two-chord drop loop: Dm -> Am -> Dm -> Am) + [1, 5, 6, 5], # i - v - VI - v (minimalist tension: Cm -> Gm -> Ab -> Gm) + [1, 4, 1, 4], # i - iv - i - iv (groovy: Am -> Dm -> Am -> Dm) + ], + 'trance': [ + [1, 5, 6, 4], # I - V - vi - IV + [6, 4, 1, 5], # vi - IV - I - V + [1, 4, 6, 5], # I - IV - vi - V + ], +} + +# Configuraciones por género +GENRE_CONFIGS = { + 'techno': { + 'bpm_range': (125, 140), + 'default_bpm': 132, + 'keys': ['Am', 'Fm', 'Dm', 'G#m', 'Cm'], + 'styles': ['industrial', 'peak-time', 'dub', 'minimal', 'acid'], + }, + 'house': { + 'bpm_range': (120, 128), + 'default_bpm': 124, + 'keys': ['Am', 'Em', 'Cm', 'Gm', 'Dm', 'F#m'], + 'styles': ['deep', 'tech-house', 'progressive', 'afro', 'classic', 'funky'], + }, + 'tech-house': { + 'bpm_range': (122, 128), + 'default_bpm': 126, + 'keys': ['Am', 'Fm', 'Dm', 'Gm', 'Cm'], + 'styles': ['groovy', 'bouncy', 'minimal', 'latin', 'latin-industrial', 'jackin', 'swing', 'latin-tech-house'], + }, + 'trance': { + 'bpm_range': (135, 150), + 'default_bpm': 140, + 'keys': ['Fm', 'Am', 'Dm', 'Gm', 'Cm'], + 'styles': ['progressive', 'uplifting', 'psy', 'acid'], + }, + 'drum-and-bass': { + 'bpm_range': (160, 180), + 'default_bpm': 174, + 'keys': ['Am', 'Fm', 'Gm', 'Cm'], + 'styles': ['liquid', 'neuro', 'jump-up', 'jungle'], + }, +} + +# Colores por tipo de track +TRACK_COLORS = { + 'kick': 10, # Rojo + 'snare': 20, # Verde + 'hat': 5, # Amarillo + 'clap': 45, # Naranja + 'bass': 30, # Azul + 'synth': 50, # Rosa/Magenta + 'chords': 60, # Púrpura + 'fx': 25, # Verde claro + 'vocal': 15, # Naranja oscuro + 'pad': 55, # Purpura claro + 'perc': 20, # Verde + 'ride': 14, # Amarillo oscuro + 'technical': 58, # Gris +} + +BUS_TRACK_COLORS = { + 'drums': 10, + 'bass': 30, + 'music': 50, + 'vocal': 15, + 'fx': 25, + 'sc_trigger': 58, # Gris - track fantasma para sidechain +} + +# NTH-04: Genre-specific color palettes for visual consistency +GENRE_COLOR_PALETTES = { + 'tech-house': { + 'kick': 13, 'clap': 11, 'snare': 11, 'hat': 12, + 'bass': 35, 'sub_bass': 33, + 'synth': 53, 'chords': 51, 'pad': 55, 'pluck': 50, + 'fx': 21, 'vocal': 17, 'perc': 20, 'ride': 14, + 'technical': 58, + }, + 'techno': { + 'kick': 0, 'clap': 5, 'hat': 3, + 'bass': 30, 'synth': 45, + 'fx': 25, 'perc': 20, 'technical': 58, + }, + 'house': { + 'kick': 10, 'clap': 15, 'hat': 20, + 'bass': 34, 'synth': 50, 'chords': 55, + 'fx': 25, 'vocal': 40, 'perc': 20, 'technical': 58, + }, +} + +# Configuracion de sidechain por bus +# Cada bus puede tener sidechain desde SC TRIGGER +BUS_SIDECHAIN_CONFIG = { + 'drums': { + 'enabled': False, # Drums no suele necesitar sidechain + 'threshold': -18.0, + 'attack': 0.003, + 'release': 0.08, + 'ratio': 4.0, + }, + 'bass': { + 'enabled': True, # Sidechain clave para bass + 'threshold': -22.0, + 'attack': 0.002, + 'release': 0.12, + 'ratio': 4.5, + }, + 'music': { + 'enabled': True, # Sidechain sutil para musica + 'threshold': -26.0, + 'attack': 0.005, + 'release': 0.18, + 'ratio': 3.0, + }, + 'vocal': { + 'enabled': True, # Sidechain suave para vocal + 'threshold': -28.0, + 'attack': 0.008, + 'release': 0.22, + 'ratio': 2.5, + }, + 'fx': { + 'enabled': False, # FX generalmente sin sidechain + 'threshold': -30.0, + 'attack': 0.01, + 'release': 0.3, + 'ratio': 2.0, + }, +} + +# ============================================================================= +# FASE 3: LOUDNESS CONSISTENCY Y GAIN STAGING +# ============================================================================= +# +# CALIBRATION PHILOSOPHY: +# ====================== +# - Kick sits at unity (0.85) as the rhythmic anchor +# - Bass sits slightly below kick (-1dB) for low-end presence without mud +# - Supporting elements progressively lower to create mix depth +# - Buses attenuated to preserve master headroom +# - Master chain with soft limiting for consistent output +# +# HEADROOM TARGETS: +# ================= +# - Track peaks: -6dB to -3dB before bus +# - Bus peaks: -3dB to -1dB before master +# - Master out: -1dB peak (limited), integrated LUFS ~-10 to -8 + +# Headroom target en dB (negativo para dejar espacio antes del limiter) +TARGET_HEADROOM_DB = -1.5 # 1.5dB de headroom antes del limiter + +# Safe limiting threshold - prevents digital clipping +MASTER_LIMITER_CEILING_DB = -0.3 # Never go above -0.3dBFS on master + +# Calibracion de ganancia por bus (valores lineales 0.0-1.0) +# Calibrado empiricamente para headroom consistente y balance de mezcla +# K: Drums como elemento principal, B: Bass como soporte, M: Music como capa +BUS_GAIN_CALIBRATION = { + 'drums': { + 'volume': 0.92, # Drums bus: principal, mas alto + 'limiter_gain': 0.0, # Sin gain adicional en limiter de bus + 'compressor_threshold': -16.0, # Compression suave para punch + 'saturator_drive': 0.6, # armonia sutil, no crunchy + 'utility_gain': 0.0, # Sin gain adicional + }, + 'bass': { + 'volume': 0.88, # Bass bus: soporte fuerte + 'limiter_gain': 0.0, # Sin limiter en bass bus (soft clip natural) + 'compressor_threshold': -18.0, # Threshold suave para low-end + 'saturator_drive': 0.4, # Saturacion sutil - evitar crunch + 'utility_gain': 0.0, # Sin gain adicional + }, + 'music': { + 'volume': 0.85, # Music bus: capa principal + 'limiter_gain': 0.0, # Sin limiter en music bus + 'compressor_threshold': -20.0, # Preservar transients + 'saturator_drive': 0.0, # Sin saturacion en bus de musica + 'utility_gain': 0.0, + }, + 'vocal': { + 'volume': 0.82, # Vocal bus: presente en mezcla + 'limiter_gain': 0.0, # Sin limiter + 'compressor_threshold': -16.0, # Compresion sutil para presencia + 'saturator_drive': 0.0, + 'utility_gain': 0.0, + }, + 'fx': { + 'volume': 0.78, # FX bus: efectos audibles + 'limiter_gain': 0.0, # Sin gain + 'compressor_threshold': -22.0, # Preservar dynamics + 'saturator_drive': 0.0, + 'utility_gain': 0.0, # Sin reduccion + }, + 'sc_trigger': { + 'volume': 0.0, # Track fantasma - sin audio + 'limiter_gain': 0.0, + 'compressor_threshold': 0.0, + 'saturator_drive': 0.0, + 'utility_gain': 0.0, + }, +} + +# Master chain calibracion +# Calibrado para LUFS ~-8 a -10dB con headroom de 1-2dB antes del limiter +# El limiter ceiling esta en -0.3dB para evitar digital clipping +MASTER_CALIBRATION = { + 'default': { + 'volume': 0.85, # Master at ~0dB de ganancia interna + 'utility_gain': 0.0, # Sin reduccion - volumen completo + 'stereo_width': 1.04, # Ligerisimo widening + 'saturator_drive': 0.12, # Saturacion muy sutil en master + 'compressor_ratio': 0.50, # Compresion suave (glue, no squash) + 'compressor_attack': 0.30, # Attack lento para preservar transients + 'compressor_release': 0.20, + 'limiter_gain': 3.5, # +3.5dB make-up gain para nivel moderno + 'limiter_ceiling': -0.3, # Ceiling a -0.3dBFS (safe limiting) + }, + 'warehouse': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'saturator_drive': 0.25, # Mas drive para industrial techno + 'compressor_ratio': 0.55, # Un poco mas de compresion + 'limiter_gain': 3.8, # Mas gain para industrial + 'limiter_ceiling': -0.3, + }, + 'festival': { + 'volume': 0.86, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.06, # Mas ancho para festival + 'limiter_gain': 4.0, # Maximo gain para festival + 'limiter_ceiling': -0.3, + }, + 'swing': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'saturator_drive': 0.15, # Moderado + 'limiter_gain': 3.2, + 'limiter_ceiling': -0.3, + }, + 'jackin': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'compressor_ratio': 0.52, + 'limiter_gain': 3.0, + 'limiter_ceiling': -0.3, + }, + 'tech-house-club': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.04, + 'saturator_drive': 0.4, # Mas drive para punch + 'compressor_ratio': 0.60, # Mas compresion para club + 'compressor_attack': 0.28, + 'limiter_gain': 3.5, + 'limiter_ceiling': -0.3, + }, + 'tech-house-deep': { + 'volume': 0.85, + 'utility_gain': 0.0, # Sin reduccion + 'stereo_width': 1.02, # Narrower para deep + 'saturator_drive': 0.1, # Muy sutil + 'compressor_ratio': 0.50, + 'compressor_attack': 0.38, # Mas lento para deep + 'limiter_gain': 3.0, + 'limiter_ceiling': -0.3, + }, + 'tech-house-funky': { + 'volume': 0.85, + 'utility_gain': 0.0, + 'stereo_width': 1.08, # Wide para groove + 'saturator_drive': 0.3, + 'compressor_ratio': 0.55, + 'compressor_attack': 0.30, + 'limiter_gain': 3.5, + 'limiter_ceiling': -0.3, + }, +} + +# Calibracion de gain por rol para consistencia de mezcla +# Valores calibrados empiricamente basados en: +# - Kick como ancla a 0.85 +# - Bass -1dB relativo a kick +# - Elementos de soporte progresivamente mas bajos +# - Headroom preservado en cada capa +ROLE_GAIN_CALIBRATION = { + # DRUMS - Kick es el ancla, otros elementos debajo + 'kick': { + 'volume': 0.85, # Ancla: 0dB relativo, elemento principal + 'saturator_drive': 1.5, # Saturacion sutil para punch + 'peak_reduction': 0.0, # Sin reduccion - es el ancla + }, + 'clap': { + 'volume': 0.78, # -1.5dB relativo a kick + 'saturator_drive': 0.0, # Sin saturacion + 'peak_reduction': 0.0, + }, + 'snare_fill': { + 'volume': 0.72, # -3dB, transitorio fuerte + 'peak_reduction': 0.0, + }, + 'hat_closed': { + 'volume': 0.68, # -4dB, elemento secundario + 'peak_reduction': 0.0, + }, + 'hat_open': { + 'volume': 0.65, # -4.5dB, mas abajo por sustain + 'peak_reduction': 0.0, + }, + 'top_loop': { + 'volume': 0.62, # -5dB, capa ritmica secundaria + 'peak_reduction': 0.0, + }, + 'perc': { + 'volume': 0.70, # -3.5dB, soporte ritmico + 'peak_reduction': 0.0, + }, + 'ride': { + 'volume': 0.58, # -5.5dB, sustain largo + 'peak_reduction': 0.0, + }, + 'crash': { + 'volume': 0.50, # -7dB, transitorio largo + 'peak_reduction': 0.0, + }, + 'tom_fill': { + 'volume': 0.68, # -4dB, transitorio + 'peak_reduction': 0.0, + }, + # BASS - Underground but underneath drums + 'sub_bass': { + 'volume': 0.80, # -0.5dB relativo a kick + 'saturator_drive': 0.0, # Sin saturacion en sub + 'peak_reduction': 0.0, + }, + 'bass': { + 'volume': 0.78, # -1dB relativo a kick + 'saturator_drive': 2.0, # Moderado para harmonic content + 'peak_reduction': 0.0, + }, + # MUSIC - Capas de soporte, debajo del low-end + 'drone': { + 'volume': 0.55, # -7dB, elemento de fondo + 'peak_reduction': 0.0, + }, + 'chords': { + 'volume': 0.70, # -3dB, armonia principal + 'peak_reduction': 0.0, + }, + 'stab': { + 'volume': 0.65, # -4dB, transitorio + 'saturator_drive': 1.8, # Moderado + 'peak_reduction': 0.0, + }, + 'pad': { + 'volume': 0.60, # -5dB, fondo armonico + 'peak_reduction': 0.0, + }, + 'pluck': { + 'volume': 0.68, # -3.5dB, melodia sutil + 'peak_reduction': 0.0, + }, + 'arp': { + 'volume': 0.65, # -4dB, movimiento armonico + 'peak_reduction': 0.0, + }, + 'lead': { + 'volume': 0.72, # -2.5dB, elemento principal musical + 'saturator_drive': 1.2, # Moderado + 'peak_reduction': 0.0, + }, + 'counter': { + 'volume': 0.62, # -5dB, contramelodia + 'peak_reduction': 0.0, + }, + # FX - Efectos en el fondo de la mezcla + 'reverse_fx': { + 'volume': 0.52, # -7dB, efecto ambiente + 'peak_reduction': 0.0, + }, + 'riser': { + 'volume': 0.60, # -5dB, sube hacia el climax + 'peak_reduction': 0.0, + }, + 'impact': { + 'volume': 0.55, # -6dB, efecto puntual + 'peak_reduction': 0.0, + }, + 'atmos': { + 'volume': 0.50, # -8dB, fondo atmosferico + 'peak_reduction': 0.0, + }, + # VOCAL + 'vocal': { + 'volume': 0.70, # -3dB, debajo de drums pero presente + 'peak_reduction': 0.0, + }, + # SC TRIGGER - Track fantasma para sidechain + 'sc_trigger': { + 'volume': 0.0, # Sin salida de audio + 'saturator_drive': 0.0, + 'peak_reduction': 0.0, + }, +} + +# Factores de ajuste por estilo +# NOTA: NO usar multiplicadores de volumen que rompan el gain staging +# Solo ajustes sutiles de procesamiento y sends +STYLE_GAIN_ADJUSTMENTS = { + 'industrial': { + 'saturator_drive_factor': 1.3, # Aumentar drive en elementos agresivos + 'additional_heat_send': 0.05, # Un poco mas de heat + 'limiter_gain_factor': 1.15, # +15% gain para industrial techno + }, + 'latin': { + 'additional_pan_width': 0.05, + }, + 'peak-time': { + 'master_compressor_ratio_factor': 1.1, + 'limiter_gain_factor': 1.1, # +10% gain para peak-time + }, + 'minimal': { + 'fx_bus_send_reduction': 0.05, + 'additional_space_send': 0.03, # Un poco mas de reverb para espacio + }, +} + +ROLE_BUS_ASSIGNMENTS = { + 'sc_trigger': 'sc_trigger', # Rutea a su propio bus fantasma + 'kick': 'drums', + 'clap': 'drums', + 'snare_fill': 'drums', + 'hat_closed': 'drums', + 'hat_open': 'drums', + 'top_loop': 'drums', + 'perc': 'drums', + 'tom_fill': 'drums', + 'ride': 'drums', + 'crash': 'drums', + 'sub_bass': 'bass', + 'bass': 'bass', + 'drone': 'music', + 'chords': 'music', + 'stab': 'music', + 'pad': 'music', + 'pluck': 'music', + 'arp': 'music', + 'lead': 'music', + 'counter': 'music', + 'reverse_fx': 'fx', + 'riser': 'fx', + 'impact': 'fx', + 'atmos': 'fx', + 'vocal': 'vocal', +} + +SECTION_BLUEPRINTS = { + 'minimal': [ + ('INTRO', 8, 12, 'intro', 1), + ('GROOVE', 16, 20, 'build', 2), + ('BREAK', 8, 25, 'break', 1), + ('OUTRO', 8, 8, 'outro', 1), + ], + 'standard': [ + ('INTRO', 8, 12, 'intro', 1), + ('BUILD', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAK', 8, 25, 'break', 1), + ('DROP B', 16, 30, 'drop', 5), + ('OUTRO', 8, 8, 'outro', 1), + ], + 'extended': [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('BUILD A', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 25, 'break', 1), + ('BUILD B', 8, 18, 'build', 3), + ('DROP B', 16, 30, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], + 'club': [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('GROOVE A', 16, 14, 'build', 2), + ('VOCAL BUILD', 8, 18, 'build', 3), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 25, 'break', 1), + ('BUILD B', 8, 18, 'build', 3), + ('DROP B', 16, 30, 'drop', 5), + ('PEAK', 8, 32, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], + 'tech-house-dj': [ + ('INTRO DJ', 32, 8, 'intro', 1), + ('GROOVE A', 16, 16, 'build', 2), + ('VOX TEASE', 8, 20, 'build', 3), + ('DROP A', 32, 30, 'drop', 5), + ('BREAK', 8, 22, 'break', 1), + ('BUILD', 8, 24, 'build', 3), + ('DROP B', 32, 32, 'drop', 5), + ('OUTRO DJ', 32, 8, 'outro', 1), + ], +} + +SECTION_BLUEPRINT_VARIANTS = { + 'standard': [ + SECTION_BLUEPRINTS['standard'], + [ + ('INTRO', 8, 12, 'intro', 1), + ('GROOVE A', 8, 16, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 24, 'break', 1), + ('BUILD B', 8, 20, 'build', 3), + ('DROP B', 16, 31, 'drop', 5), + ], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('BUILD', 8, 18, 'build', 2), + ('DROP A', 16, 28, 'drop', 4), + ('MID BREAK', 8, 22, 'break', 1), + ('PEAK', 16, 31, 'drop', 5), + ], + ], + 'club': [ + SECTION_BLUEPRINTS['club'], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('TEASE', 8, 14, 'build', 2), + ('GROOVE A', 16, 18, 'build', 3), + ('DROP A', 16, 28, 'drop', 4), + ('BREAKDOWN', 8, 24, 'break', 1), + ('BUILD B', 8, 20, 'build', 3), + ('PEAK', 16, 32, 'drop', 5), + ('OUTRO DJ', 24, 8, 'outro', 1), + ], + [ + ('INTRO DJ', 16, 10, 'intro', 1), + ('GROOVE A', 16, 15, 'build', 2), + ('VOCAL BUILD', 8, 20, 'build', 3), + ('DROP A', 16, 27, 'drop', 4), + ('MID BREAK', 8, 22, 'break', 1), + ('GROOVE B', 8, 18, 'build', 3), + ('DROP B', 24, 31, 'drop', 5), + ('OUTRO DJ', 16, 8, 'outro', 1), + ], + ], +} + +ROLE_ACTIVITY = { + 'sc_trigger': {'intro': 4, 'build': 4, 'drop': 4, 'break': 2, 'outro': 3}, + 'kick': {'intro': 2, 'build': 3, 'drop': 4, 'break': 1, 'outro': 2}, + 'clap': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'snare_fill': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'hat_closed': {'intro': 1, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'hat_open': {'intro': 0, 'build': 1, 'drop': 3, 'break': 0, 'outro': 1}, + 'top_loop': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'perc': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, + 'tom_fill': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, + 'ride': {'intro': 0, 'build': 1, 'drop': 2, 'break': 0, 'outro': 1}, + 'crash': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, + 'sub_bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, + 'drone': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, + 'chords': {'intro': 0, 'build': 2, 'drop': 3, 'break': 2, 'outro': 1}, + 'stab': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 0}, + 'pad': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, + 'pluck': {'intro': 0, 'build': 2, 'drop': 3, 'break': 0, 'outro': 0}, + 'arp': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, + 'lead': {'intro': 0, 'build': 1, 'drop': 4, 'break': 0, 'outro': 0}, + 'counter': {'intro': 0, 'build': 1, 'drop': 3, 'break': 1, 'outro': 0}, + 'reverse_fx': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'riser': {'intro': 0, 'build': 3, 'drop': 1, 'break': 2, 'outro': 0}, + 'impact': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, + 'atmos': {'intro': 2, 'build': 1, 'drop': 1, 'break': 3, 'outro': 2}, + 'vocal': {'intro': 0, 'build': 1, 'drop': 2, 'break': 1, 'outro': 0}, +} + +# ROLE_MIX: Perfil de mezcla por rol +# Valores base que luego se calibran con ROLE_GAIN_CALIBRATION +# Volumenes calibrados relativos: kick = 0%, otros debajo +# Pan y sends optimizados para profundidad y espacio +ROLE_MIX = { + 'sc_trigger': {'volume': 0.0, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.0}}, + # DRUMS - Kick centered, elements below + 'kick': {'volume': 0.85, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.08}}, + 'clap': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.14, 'echo': 0.04, 'heat': 0.02, 'glue': 0.10}}, + 'snare_fill': {'volume': 0.72, 'pan': 0.0, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, + 'hat_closed': {'volume': 0.68, 'pan': -0.10, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.04}}, + 'hat_open': {'volume': 0.65, 'pan': 0.12, 'sends': {'space': 0.10, 'echo': 0.08, 'heat': 0.01, 'glue': 0.06}}, + 'top_loop': {'volume': 0.62, 'pan': -0.16, 'sends': {'space': 0.06, 'echo': 0.12, 'heat': 0.0, 'glue': 0.08}}, + 'perc': {'volume': 0.70, 'pan': 0.20, 'sends': {'space': 0.10, 'echo': 0.14, 'heat': 0.02, 'glue': 0.10}}, + 'tom_fill': {'volume': 0.68, 'pan': 0.12, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, + 'ride': {'volume': 0.58, 'pan': 0.24, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.06}}, + 'crash': {'volume': 0.50, 'pan': 0.0, 'sends': {'space': 0.18, 'echo': 0.06, 'heat': 0.01, 'glue': 0.02}}, + # BASS - Below drums, centered for mono compatibility + 'sub_bass': {'volume': 0.80, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.14}}, + 'bass': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.01, 'echo': 0.01, 'heat': 0.04, 'glue': 0.12}}, + # MUSIC - Layers below rhythm section + 'drone': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.08, 'heat': 0.02, 'glue': 0.04}}, + 'chords': {'volume': 0.70, 'pan': -0.06, 'sends': {'space': 0.18, 'echo': 0.12, 'heat': 0.01, 'glue': 0.08}}, + 'stab': {'volume': 0.65, 'pan': 0.10, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.04, 'glue': 0.08}}, + 'pad': {'volume': 0.60, 'pan': -0.14, 'sends': {'space': 0.32, 'echo': 0.08, 'heat': 0.0, 'glue': 0.06}}, + 'pluck': {'volume': 0.68, 'pan': 0.14, 'sends': {'space': 0.08, 'echo': 0.18, 'heat': 0.01, 'glue': 0.06}}, + 'arp': {'volume': 0.65, 'pan': -0.18, 'sends': {'space': 0.14, 'echo': 0.24, 'heat': 0.01, 'glue': 0.08}}, + 'lead': {'volume': 0.72, 'pan': 0.06, 'sends': {'space': 0.14, 'echo': 0.18, 'heat': 0.03, 'glue': 0.10}}, + 'counter': {'volume': 0.62, 'pan': 0.20, 'sends': {'space': 0.18, 'echo': 0.14, 'heat': 0.01, 'glue': 0.06}}, + # FX - Deep in the mix + 'reverse_fx': {'volume': 0.52, 'pan': 0.0, 'sends': {'space': 0.24, 'echo': 0.10, 'heat': 0.03, 'glue': 0.02}}, + 'riser': {'volume': 0.60, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.14, 'heat': 0.04, 'glue': 0.03}}, + 'impact': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.22, 'echo': 0.12, 'heat': 0.01, 'glue': 0.03}}, + 'atmos': {'volume': 0.50, 'pan': -0.20, 'sends': {'space': 0.34, 'echo': 0.06, 'heat': 0.0, 'glue': 0.03}}, + # VOCAL - Present but under drums + 'vocal': {'volume': 0.70, 'pan': 0.08, 'sends': {'space': 0.20, 'echo': 0.24, 'heat': 0.02, 'glue': 0.10}}, +} + +ARRANGEMENT_PROFILES = ( + { + 'name': 'warehouse', + 'genres': {'techno', 'tech-house'}, + 'drum_tightness': 1.15, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.12, + 'fx_bias': 1.0, + }, + { + 'name': 'jackin', + 'genres': {'house', 'tech-house'}, + 'drum_tightness': 0.96, + 'bass_motion': 'bouncy', + 'melodic_motion': 'call_response', + 'pan_width': 0.16, + 'fx_bias': 0.92, + }, + { + 'name': 'festival', + 'genres': {'trance', 'house', 'tech-house'}, + 'drum_tightness': 0.92, + 'bass_motion': 'lifted', + 'melodic_motion': 'anthemic', + 'pan_width': 0.2, + 'fx_bias': 1.18, + }, + { + 'name': 'swing', + 'genres': {'tech-house', 'house'}, + 'drum_tightness': 0.9, + 'bass_motion': 'syncopated', + 'melodic_motion': 'hooky', + 'pan_width': 0.22, + 'fx_bias': 1.05, + }, + { + 'name': 'tech-house-club', + 'genres': {'tech-house'}, + 'drum_tightness': 0.94, + 'bass_motion': 'bouncy', + 'melodic_motion': 'hooky', + 'pan_width': 0.18, + 'fx_bias': 1.08, + 'bus_names': { + 'drums': 'DRUM CLUB', + 'bass': 'BASS TUBE', + 'music': 'MUSIC JACK', + 'vocal': 'VOCAL LATIN BUS', + 'fx': 'FX JAM', + }, + 'return_names': { + 'space': 'REVERB SHORT', + 'echo': 'DELAY MONO', + 'heat': 'DRIVE HOT', + 'glue': 'GLUE BUS', + }, + }, + { + 'name': 'tech-house-deep', + 'genres': {'tech-house'}, + 'drum_tightness': 1.02, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.14, + 'fx_bias': 0.88, + 'bus_names': { + 'drums': 'DRUM DEEP', + 'bass': 'SUB DEEP', + 'music': 'ATMOS DEEP', + 'vocal': 'VOX DEEP', + 'fx': 'FX DEEP', + }, + 'return_names': { + 'space': 'REVERB DEEP', + 'echo': 'DELAY DEEP', + 'heat': 'SATURATE DEEP', + 'glue': 'GLUE MINIMAL', + }, + }, + { + 'name': 'tech-house-funky', + 'genres': {'tech-house'}, + 'drum_tightness': 0.86, + 'bass_motion': 'syncopated', + 'melodic_motion': 'hooky', + 'pan_width': 0.24, + 'fx_bias': 1.12, + 'bus_names': { + 'drums': 'DRUM GROOVE', + 'bass': 'BASS FUNK', + 'music': 'MUSIC GROOVE', + 'vocal': 'VOCAL FUNK', + 'fx': 'FX SWING', + }, + 'return_names': { + 'space': 'REVERB GROOVE', + 'echo': 'DELAY GROOVE', + 'heat': 'DRIVE FUNK', + 'glue': 'GLUE SWING', + }, + }, +) + +ROLE_FX_CHAINS = { + 'sc_trigger': [ + {'device': 'Utility', 'parameters': {'Gain': 0.0, 'Width': 0.0}}, + ], + 'kick': [ + {'device': 'Saturator', 'parameters': {'Drive': 2.5}}, + ], + 'clap': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.08}}, + ], + 'snare_fill': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}, + ], + 'hat_closed': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 15000.0, 'Dry/Wet': 0.14}}, + ], + 'hat_open': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.18}}, + ], + 'top_loop': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 11000.0, 'Dry/Wet': 0.22}}, + ], + 'perc': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 9500.0, 'Dry/Wet': 0.16}}, + ], + 'ride': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 12500.0, 'Dry/Wet': 0.12}}, + ], + 'sub_bass': [ + {'device': 'Utility', 'parameters': {'Width': 0.0}}, + ], + 'bass': [ + {'device': 'Saturator', 'parameters': {'Drive': 4.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 7800.0, 'Dry/Wet': 0.12}}, + ], + 'drone': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'chords': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.14}}, + ], + 'stab': [ + {'device': 'Saturator', 'parameters': {'Drive': 3.0}}, + ], + 'pad': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, + ], + 'pluck': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'arp': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'lead': [ + {'device': 'Saturator', 'parameters': {'Drive': 2.0}}, + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'counter': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.1}}, + ], + 'crash': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, + ], + 'reverse_fx': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.24}}, + ], + 'riser': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.28}}, + ], + 'impact': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, + ], + 'atmos': [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.3}}, + ], + 'vocal': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.14}}, + ], +} + +SCRIPTS_ROOT = Path(__file__).resolve().parents[2] +REFERENCE_SEARCH_DIRS = ( + SCRIPTS_ROOT / 'sample', + SCRIPTS_ROOT / 'samples', +) +REFERENCE_TRACK_PROFILES = [ + { + 'name': 'Eli Brown x GeezLy - Me Gusta', + 'match_terms': ['eli brown', 'geezly', 'me gusta'], + 'genre': 'tech-house', + 'style': 'latin-industrial', + 'bpm': 136.0, + 'key': 'F#m', + 'structure': 'club', + 'reference_bars': 112, + }, + { + 'name': 'Mr. Pauer, Goyo - Química', + 'match_terms': ['mr. pauer', 'goyo', 'química'], + 'genre': 'house', + 'style': 'latin-funky vocal', + 'bpm': 123.0, + 'key': 'Cm', + 'structure': 'extended', + 'reference_bars': 72, + }, +] + +# ========================================================================= +# SECTION AUTOMATION PARAMETERS +# ========================================================================= + +SECTION_AUTOMATION = { + 'intro': { + 'energy': 0.25, + 'filters': { + 'drums': {'frequency': 8500.0, 'resonance': 0.3, 'dry_wet': 0.12}, + 'bass': {'frequency': 6200.0, 'resonance': 0.25, 'dry_wet': 0.08}, + 'music': {'frequency': 7800.0, 'resonance': 0.2, 'dry_wet': 0.1}, + 'vocal': {'frequency': 9200.0, 'resonance': 0.15, 'dry_wet': 0.06}, + 'fx': {'frequency': 8800.0, 'resonance': 0.18, 'dry_wet': 0.14}, + }, + 'reverb': {'send_level': 0.28, 'decay_time': 2.8, 'size': 0.85}, + 'delay': {'send_level': 0.18, 'feedback': 0.35, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -14.0, 'ratio': 2.0, 'attack': 0.015, 'release': 0.12}, + 'saturation': {'drive': 0.8, 'mix': 0.15}, + 'stereo_width': {'value': 0.92}, + 'envelope_curve': 'ease_in', + }, + 'build': { + 'energy': 0.72, + 'filters': { + 'drums': {'frequency': 4200.0, 'resonance': 0.45, 'dry_wet': 0.22}, + 'bass': {'frequency': 3800.0, 'resonance': 0.35, 'dry_wet': 0.16}, + 'music': {'frequency': 5400.0, 'resonance': 0.28, 'dry_wet': 0.18}, + 'vocal': {'frequency': 6800.0, 'resonance': 0.22, 'dry_wet': 0.12}, + 'fx': {'frequency': 5200.0, 'resonance': 0.32, 'dry_wet': 0.24}, + }, + 'reverb': {'send_level': 0.18, 'decay_time': 2.2, 'size': 0.72}, + 'delay': {'send_level': 0.32, 'feedback': 0.48, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -10.0, 'ratio': 3.5, 'attack': 0.008, 'release': 0.08}, + 'saturation': {'drive': 2.2, 'mix': 0.28}, + 'stereo_width': {'value': 1.08}, + 'envelope_curve': 'ramp_up', + }, + 'drop': { + 'energy': 1.0, + 'filters': { + 'drums': {'frequency': 14500.0, 'resonance': 0.2, 'dry_wet': 0.04}, + 'bass': {'frequency': 9800.0, 'resonance': 0.15, 'dry_wet': 0.03}, + 'music': {'frequency': 12200.0, 'resonance': 0.12, 'dry_wet': 0.05}, + 'vocal': {'frequency': 12800.0, 'resonance': 0.1, 'dry_wet': 0.04}, + 'fx': {'frequency': 11000.0, 'resonance': 0.15, 'dry_wet': 0.08}, + }, + 'reverb': {'send_level': 0.12, 'decay_time': 1.6, 'size': 0.55}, + 'delay': {'send_level': 0.14, 'feedback': 0.28, 'time_l': 0.25, 'time_r': 0.375}, + 'compression': {'threshold': -6.0, 'ratio': 4.5, 'attack': 0.005, 'release': 0.06}, + 'saturation': {'drive': 3.5, 'mix': 0.38}, + 'stereo_width': {'value': 1.18}, + 'envelope_curve': 'punch', + }, + 'break': { + 'energy': 0.38, + 'filters': { + 'drums': {'frequency': 5200.0, 'resonance': 0.55, 'dry_wet': 0.32}, + 'bass': {'frequency': 2800.0, 'resonance': 0.45, 'dry_wet': 0.24}, + 'music': {'frequency': 6400.0, 'resonance': 0.35, 'dry_wet': 0.22}, + 'vocal': {'frequency': 8200.0, 'resonance': 0.28, 'dry_wet': 0.16}, + 'fx': {'frequency': 6800.0, 'resonance': 0.38, 'dry_wet': 0.28}, + }, + 'reverb': {'send_level': 0.42, 'decay_time': 3.5, 'size': 1.0}, + 'delay': {'send_level': 0.38, 'feedback': 0.52, 'time_l': 0.5, 'time_r': 0.75}, + 'compression': {'threshold': -18.0, 'ratio': 1.8, 'attack': 0.025, 'release': 0.18}, + 'saturation': {'drive': 0.5, 'mix': 0.1}, + 'stereo_width': {'value': 1.25}, + 'envelope_curve': 'ease_out', + }, + 'outro': { + 'energy': 0.32, + 'filters': { + 'drums': {'frequency': 6200.0, 'resonance': 0.35, 'dry_wet': 0.18}, + 'bass': {'frequency': 4200.0, 'resonance': 0.28, 'dry_wet': 0.14}, + 'music': {'frequency': 5600.0, 'resonance': 0.25, 'dry_wet': 0.16}, + 'vocal': {'frequency': 7200.0, 'resonance': 0.2, 'dry_wet': 0.1}, + 'fx': {'frequency': 6400.0, 'resonance': 0.28, 'dry_wet': 0.2}, + }, + 'reverb': {'send_level': 0.35, 'decay_time': 3.2, 'size': 0.92}, + 'delay': {'send_level': 0.28, 'feedback': 0.42, 'time_l': 0.375, 'time_r': 0.5}, + 'compression': {'threshold': -12.0, 'ratio': 2.2, 'attack': 0.018, 'release': 0.15}, + 'saturation': {'drive': 0.6, 'mix': 0.12}, + 'stereo_width': {'value': 0.98}, + 'envelope_curve': 'ease_out', + }, +} + +# Envelope curve templates for automation interpolation +ENVELOPE_CURVES = { + 'linear': lambda x: x, + 'ease_in': lambda x: x * x, + 'ease_out': lambda x: 1 - (1 - x) ** 2, + 'ease_in_out': lambda x: 3 * x * x - 2 * x * x * x, + 'ramp_up': lambda x: x ** 0.5, + 'ramp_down': lambda x: 1 - (1 - x) ** 2, + 'punch': lambda x: min(1.0, x * 2.0) if x < 0.5 else 1.0 - (1.0 - x) ** 0.5, + 's_curve': lambda x: 1 / (1 + (2.71828 ** (-10 * (x - 0.5)))), + 'exponential': lambda x: (2.71828 ** (x - 1) - 0.3679) / 0.6321, +} + +# ============================================================================= +# AUTOMATIZACION DE DEVICES POR SECCION - FASE 2 +# Parametros especificos por device para cada tipo de seccion +# ============================================================================= + +# Automatizacion de devices en tracks individuales por rol - ENHANCED +SECTION_DEVICE_AUTOMATION = { + # BASS - Filtros, drive y compresion dinamica + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + }, + 'Compressor': { + 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + }, + 'sub_bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, + }, + 'Utility': { + 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, + }, + }, + # PAD - Filtros envolventes con width y reverb + 'pad': { + 'Auto Filter': { + 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, + 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, + 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, + 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, + }, + }, + # ATMOS - Filtros espaciales con movement + 'atmos': { + 'Auto Filter': { + 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, + 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, + 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, + 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, + }, + }, + # FX ELEMENTS + 'reverse_fx': { + 'Auto Filter': { + 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, + 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, + }, + }, + 'riser': { + 'Auto Filter': { + 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, + 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, + }, + }, + 'impact': { + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, + 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, + }, + 'Saturator': { + 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, + }, + }, + 'drone': { + 'Auto Filter': { + 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, + 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, + 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, + }, + }, + # HATS - Filtros de brillantez con resonance y saturacion + 'hat_closed': { + 'Auto Filter': { + 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, + 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, + }, + }, + 'hat_open': { + 'Auto Filter': { + 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, + }, + }, + 'top_loop': { + 'Auto Filter': { + 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, + 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, + }, + }, + # SYNTHS + 'chords': { + 'Auto Filter': { + 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, + 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, + }, + 'Saturator': { + 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, + }, + }, + 'lead': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, + }, + 'Echo': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, + 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, + }, + }, + 'stab': { + 'Saturator': { + 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, + 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, + }, + }, + 'pluck': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, + 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, + }, + }, + 'arp': { + 'Echo': { + 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, + 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, + }, + 'Saturator': { + 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, + }, + }, + 'counter': { + 'Echo': { + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, + }, + }, + # VOCAL + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, + 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, + }, + }, + # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) + 'kick': {}, + 'clap': {}, + 'snare_fill': {}, + 'perc': {}, + 'ride': {}, + 'tom_fill': {}, + 'crash': {}, + 'sc_trigger': {}, +} + +# Automatizacion de devices en BUSES por seccion - ENHANCED +BUS_DEVICE_AUTOMATION = { + 'drums': { + 'Compressor': { + 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, + 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, + 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, + }, + 'Saturator': { + 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, + }, + 'Limiter': { + 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, + }, + 'AutoFilter': { + 'Frequency': {'intro': 8500.0, 'build': 12500.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6500.0}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.22, 'drop': 0.04, 'break': 0.35, 'outro': 0.18}, + 'Resonance': {'intro': 0.20, 'build': 0.12, 'drop': 0.08, 'break': 0.50, 'outro': 0.28}, + }, + }, + 'bass': { + 'Saturator': { + 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, + 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, + }, + 'Compressor': { + 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, + 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, + }, + 'Utility': { + 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 4800.0, 'build': 8500.0, 'drop': 12000.0, 'break': 3200.0, 'outro': 4200.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.05, 'break': 0.25, 'outro': 0.12}, + 'Resonance': {'intro': 0.18, 'build': 0.12, 'drop': 0.08, 'break': 0.45, 'outro': 0.22}, + }, + }, + 'music': { + 'Compressor': { + 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, + 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, + 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 7500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6000.0}, + 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.03, 'break': 0.30, 'outro': 0.15}, + 'Resonance': {'intro': 0.18, 'build': 0.10, 'drop': 0.06, 'break': 0.40, 'outro': 0.22}, + }, + 'Utility': { + 'Stereo Width': {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.25, 'outro': 1.05}, + }, + 'Saturator': { + 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, + 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, + }, + }, + 'vocal': { + 'Echo': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.12, 'drop': 0.05, 'break': 0.18, 'outro': 0.08}, + 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.28, 'break': 0.50, 'outro': 0.30}, + }, + 'Compressor': { + 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, + 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.03, 'break': 0.16, 'outro': 0.08}, + 'Decay Time': {'intro': 2.2, 'build': 3.0, 'drop': 1.6, 'break': 4.0, 'outro': 2.5}, + }, + 'Auto Filter': { + 'Frequency': {'intro': 8000.0, 'build': 11500.0, 'drop': 14500.0, 'break': 6000.0, 'outro': 7200.0}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.04, 'break': 0.22, 'outro': 0.10}, + 'Resonance': {'intro': 0.15, 'build': 0.10, 'drop': 0.06, 'break': 0.32, 'outro': 0.18}, + }, + }, + 'fx': { + 'Auto Filter': { + 'Frequency': {'intro': 6000.0, 'build': 10500.0, 'drop': 14000.0, 'break': 4000.0, 'outro': 5200.0}, + 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.06, 'outro': 0.18, 'break': 0.35}, + 'Resonance': {'intro': 0.18, 'build': 0.15, 'drop': 0.10, 'break': 0.42, 'outro': 0.22}, + }, + 'Hybrid Reverb': { + 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.10, 'break': 0.38, 'outro': 0.22}, + 'Decay Time': {'intro': 3.0, 'build': 3.8, 'drop': 2.0, 'break': 5.0, 'outro': 3.5}, + }, + 'Limiter': { + 'Gain': {'intro': -0.3, 'build': 0.0, 'drop': 0.2, 'break': -0.5, 'outro': -0.2}, + }, + 'Saturator': { + 'Drive': {'intro': 0.5, 'build': 1.5, 'drop': 2.2, 'break': 0.8, 'outro': 0.6}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.14, 'drop': 0.20, 'break': 0.10, 'outro': 0.10}, + }, + }, +} + +# Automatizacion de devices en MASTER por seccion - ENHANCED +MASTER_DEVICE_AUTOMATION = { + 'Utility': {'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, + 'Gain': {'intro': 0.72, 'build': 0.88, 'drop': 1.0, 'break': 0.68, 'outro': 0.70}, + }, + 'Saturator': {'Drive': {'intro': 0.18, 'build': 0.30, 'drop': 0.45, 'break': 0.12, 'outro': 0.15}, + 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.06, 'outro': 0.10}, + }, + 'Compressor': {'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.68, 'break': 0.50, 'outro': 0.52}, + 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -13.5, 'break': -8.0, 'outro': -9.0}, + 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, + 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.10, 'break': 0.18, 'outro': 0.16}, + }, + 'Limiter': {'Gain': {'intro': 1.05, 'build': 1.12, 'drop': 1.20, 'break': 1.00, 'outro': 1.02}, + 'Ceiling': {'intro': -0.5, 'build': -0.7, 'drop': -0.9, 'break': -0.4, 'outro': -0.45}, + }, + 'Auto Filter': {'Frequency': {'intro': 8500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 5500.0, 'outro': 7500.0}, + 'Dry/Wet': {'intro': 0.04, 'build': 0.02, 'drop': 0.01, 'break': 0.06, 'outro': 0.05}, + }, + 'Echo': {'Dry/Wet': {'intro': 0.02, 'build': 0.05, 'drop': 0.03, 'break': 0.07, 'outro': 0.03}, + 'Feedback': {'intro': 0.15, 'build': 0.25, 'drop': 0.18, 'break': 0.30, 'outro': 0.20}, + }, +} + +DEVICE_PARAMETER_SAFETY_CLAMPS = { + 'Drive': {'min': 0.0, 'max': 6.0}, + 'Frequency': {'min': 20.0, 'max': 20000.0}, + 'Dry/Wet': {'min': 0.0, 'max': 1.0}, + 'Feedback': {'min': 0.0, 'max': 0.7}, + 'Stereo Width': {'min': 0.0, 'max': 1.3}, + 'Resonance': {'min': 0.0, 'max': 1.0}, + 'Ratio': {'min': 1.0, 'max': 20.0}, + 'Threshold': {'min': -60.0, 'max': 0.0}, + 'Attack': {'min': 0.0001, 'max': 0.5}, + 'Release': {'min': 0.001, 'max': 2.0}, + 'Gain': {'min': -1.0, 'max': 1.8}, + 'Decay Time': {'min': 0.1, 'max': 10.0}, +} + +MASTER_SAFETY_CLAMPS = { + 'Stereo Width': {'min': 0.0, 'max': 1.25}, + 'Drive': {'min': 0.0, 'max': 1.5}, + 'Ratio': {'min': 0.45, 'max': 0.9}, + 'Gain': {'min': 0.0, 'max': 1.6}, + 'Attack': {'min': 0.0001, 'max': 0.1}, + 'Ceiling': {'min': -3.0, 'max': 0.0}, + 'Threshold': {'min': -20.0, 'max': 0.0}, + 'Release': {'min': 0.001, 'max': 1.0}, +} + +# Expanded configuration de variación por sección +SECTION_VARIATION_CONFIG = { + 'perc': { + 'intro': {'sparse': True, 'intensity': 0.3, 'variant': 'ghost'}, + 'build': {'building': True, 'intensity': 0.8, 'variant': 'layering'}, + 'drop': {'full': True, 'intensity': 1.0, 'variant': 'layered'}, + 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'minimal'}, + 'outro': {'fading': True, 'intensity': 0.3, 'variant': 'strip_down'}, + }, + 'perc_alt': { + 'intro': {'sparse': True, 'intensity': 0.2, 'variant': 'minimal'}, + 'build': {'building': True, 'intensity': 0.6, 'variant': 'tension'}, + 'drop': {'full': True, 'intensity': 0.7, 'variant': 'groove'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'atmos'}, + 'outro': {'fading': True, 'intensity': 0.2, 'variant': 'minimal'}, + }, + 'top_loop': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.8, 'variant': 'energy'}, + 'drop': {'full': True, 'intensity': 1.0, 'variant': 'full'}, + 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'filtered'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'hat_open': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.7, 'variant': 'tease'}, + 'drop': {'full': True, 'intensity': 0.9, 'variant': 'offbeat'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'filtered'}, + 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'fading'}, + }, + 'ride': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'intensity': 0.6, 'variant': 'building'}, + 'drop': {'full': True, 'intensity': 0.8, 'variant': 'full'}, + 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'sparse'}, + 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'minimal'}, + }, + 'snare_fill': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'tension': True, 'intensity': 0.8, 'variant': 'rolling'}, + 'drop': {'impact': True, 'intensity': 0.6, 'variant': 'fill'}, + 'break': {'sparse': True, 'intensity': 0.5, 'variant': 'tension'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'tom_fill': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'rising': True, 'intensity': 0.7, 'variant': 'rising'}, + 'drop': {'impact': True, 'intensity': 0.5, 'variant': 'fill'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'vocal_shot': { + 'intro': {'sparse': True, 'variant': 'hint'}, + 'build': {'building': True, 'variant': 'anticipate'}, + 'drop': {'full': True, 'variant': 'hook'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'fading': True, 'variant': 'minimal'}, + }, + 'synth_peak': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'anthem'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'atmos': { + 'intro': {'full': True, 'decay': 'long', 'variant': 'atmospheric'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'sparse': True, 'variant': 'minimal'}, + 'break': {'full': True, 'decay': 'long', 'variant': 'ethereal'}, + 'outro': {'fading': True, 'decay': 'long', 'variant': 'fading'}, + }, + 'chords': { + 'intro': {'sparse': True, 'variant': 'foreshadow'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'full'}, + 'break': {'sparse': True, 'variant': 'atmospheric'}, + 'outro': {'fading': True, 'variant': 'echo'}, + }, + 'pad': { + 'intro': {'full': True, 'variant': 'atmospheric'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'sparse': True, 'variant': 'minimal'}, + 'break': {'full': True, 'variant': 'ethereal'}, + 'outro': {'fading': True, 'variant': 'decay'}, + }, + 'lead': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'hook'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'arp': { + 'intro': {'sparse': True, 'variant': 'ghost'}, + 'build': {'building': True, 'variant': 'energy'}, + 'drop': {'full': True, 'variant': 'driving'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'pluck': { + 'intro': {'sparse': True, 'variant': 'hint'}, + 'build': {'building': True, 'variant': 'tension'}, + 'drop': {'full': True, 'variant': 'punchy'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'fading': True, 'variant': 'strip_down'}, + }, + 'bass': { + 'intro': {'sparse': True, 'variant': 'subtle'}, + 'build': {'building': True, 'variant': 'rising'}, + 'drop': {'full': True, 'variant': 'groove'}, + 'break': {'sparse': True, 'variant': 'filtered'}, + 'outro': {'fading': True, 'variant': 'fading'}, + }, + 'sub_bass': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'building': True, 'variant': 'hint'}, + 'drop': {'full': True, 'variant': 'deep'}, + 'break': {'sparse': True, 'variant': 'minimal'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, + 'stab': { + 'intro': {'use': False, 'variant': 'absent'}, + 'build': {'sparse': True, 'variant': 'hint'}, + 'drop': {'full': True, 'variant': 'impact'}, + 'break': {'use': False, 'variant': 'absent'}, + 'outro': {'use': False, 'variant': 'absent'}, + }, +} + +# ============================================================================= +# DRUM PATTERN BANKS - Expanded variants for section-specific patterns +# ============================================================================= + +# Section-specific drum variants - EXPANDED for variation +DRUM_SECTION_VARIANTS = { + 'intro': { + 'kick': ['sparse', 'minimal', 'foreshadow', 'hint'], + 'clap': ['absent', 'hint'], + 'hat_closed': ['sparse', 'ghost', 'whisper'], + 'hat_open': ['absent', 'hint'], + 'perc': ['minimal', 'atmos', 'ghost'], + 'ride': ['absent'], + 'top_loop': ['absent', 'hint'], + 'snare_fill': ['absent'], + 'tom_fill': ['absent'], + }, + 'build': { + 'kick': ['building', 'pressure', 'rising', 'tension'], + 'clap': ['building', 'anticipate', 'roll_in'], + 'hat_closed': ['building', 'open_up', 'hyper'], + 'hat_open': ['building', 'tease'], + 'perc': ['layering', 'tension', 'build_up'], + 'ride': ['building', 'rising'], + 'top_loop': ['building', 'energy'], + 'snare_fill': ['rolling', 'tension'], + 'tom_fill': ['rising', 'fill'], + }, + 'drop': { + 'kick': ['full', 'punch', 'four_on_floor', 'groove', 'impact'], + 'clap': ['full', 'backbeat', 'syncopated', 'punch'], + 'hat_closed': ['full', 'groove', 'offbeat', 'shuffle'], + 'hat_open': ['full', 'offbeat', 'groove'], + 'perc': ['full', 'layered', 'groove', 'latin', 'tribal'], + 'ride': ['full', 'groove', 'energy'], + 'top_loop': ['full', 'energy', 'layered'], + 'snare_fill': ['drop_hit', 'fill'], + 'tom_fill': ['drop_hit', 'fill'], + }, + 'break': { + 'kick': ['sparse', 'absent', 'minimal', 'foreshadow'], + 'clap': ['sparse', 'offbeat', 'ghost'], + 'hat_closed': ['open', 'sparse', 'atmos', 'filtered'], + 'hat_open': ['sparse', 'filtered'], + 'perc': ['minimal', 'atmos', 'filtered'], + 'ride': ['sparse', 'filtered'], + 'top_loop': ['filtered', 'hint'], + 'snare_fill': ['tension'], + 'tom_fill': ['tension'], + }, + 'outro': { + 'kick': ['fading', 'minimal', 'sparse', 'strip_down'], + 'clap': ['fading', 'sparse', 'last_hit'], + 'hat_closed': ['fading', 'open', 'minimal'], + 'hat_open': ['fading', 'last_hit'], + 'perc': ['fading', 'minimal', 'strip_down'], + 'ride': ['fading', 'minimal'], + 'top_loop': ['fading', 'minimal'], + 'snare_fill': ['end_fill', 'absent'], + 'tom_fill': ['end_fill', 'absent'], + }, +} + +# Expanded drum pattern generators for section variation +DRUM_PATTERN_BANKS = { + 'kick': { + 'four_on_floor': [0.0, 1.0, 2.0, 3.0], + 'sparse': [0.0, 2.0], + 'minimal': [0.0], + 'foreshadow': [0.0, 3.5], + 'hint': [0.0, 2.5], + 'building': [0.0, 1.0, 2.0, 3.0, 3.5], + 'pressure': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'rising': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5, 3.75], + 'tension': [0.0, 0.25, 1.0, 1.5, 2.0, 2.75, 3.0, 3.25, 3.5], + 'full': [0.0, 1.0, 2.0, 3.0], + 'punch': [0.0, 0.25, 1.0, 2.0, 3.0], + 'groove': [0.0, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.75], + 'impact': [0.0, 0.25, 0.5, 1.0, 2.0, 3.0], + 'fading': [0.0, 2.0], + 'strip_down': [0.0], + 'absent': [], + }, + 'clap': { + 'backbeat': [1.0, 3.0], + 'sparse': [1.0], + 'hint': [3.0], + 'building': [1.0, 2.5, 3.0], + 'anticipate': [1.0, 2.0, 2.75, 3.0, 3.5], + 'roll_in': [0.75, 1.0, 1.25, 1.5, 2.75, 3.0, 3.25, 3.5], + 'full': [1.0, 3.0], + 'syncopated': [0.75, 1.0, 2.75, 3.0], + 'offbeat': [1.5, 3.5], + 'punch': [0.75, 1.0, 1.25, 2.75, 3.0, 3.25], + 'ghost': [3.0], + 'last_hit': [1.0], + 'fading': [1.0], + 'absent': [], + }, + 'hat_closed': { + 'offbeat': [0.5, 1.5, 2.5, 3.5], + 'sparse': [0.5, 2.5], + 'ghost': [0.25, 1.25, 2.25, 3.25], + 'whisper': [0.75, 1.75, 2.75, 3.75], + 'building': [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'open_up': [0.5, 0.75, 1.5, 1.75, 2.5, 2.75, 3.5, 3.75], + 'hyper': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'groove': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'shuffle': [0.0, 0.33, 0.66, 1.0, 1.33, 1.66, 2.0, 2.33, 2.66, 3.0, 3.33, 3.66], + 'filtered': [0.5, 1.5, 2.5, 3.5], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'fading': [0.5, 2.5], + 'minimal': [0.5], + }, + 'hat_open': { + 'sparse': [2.0], + 'building': [1.5, 2.5, 3.0], + 'full': [0.0, 2.0], + 'offbeat': [1.5, 3.5], + 'tease': [3.5], + 'fading': [2.0], + 'last_hit': [3.5], + 'hint': [2.0], + 'absent': [], + }, + 'perc': { + 'minimal': [1.5], + 'atmos': [0.75, 2.75], + 'ghost': [0.25, 2.25], + 'layering': [0.5, 1.5, 2.5, 3.5], + 'tension': [0.25, 1.25, 2.25, 3.25], + 'build_up': [0.5, 1.0, 2.0, 3.0, 3.5], + 'full': [0.5, 1.0, 1.5, 2.5, 3.0, 3.5], + 'layered': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'groove': [0.5, 1.0, 2.0, 2.5, 3.5], + 'latin': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'tribal': [0.0, 0.5, 1.25, 1.75, 2.5, 3.0, 3.75], + 'filtered': [0.5, 2.5], + 'fading': [1.5], + 'strip_down': [0.0], + 'hint': [2.0], + }, + 'ride': { + 'sparse': [0.0, 2.0], + 'building': [0.0, 1.0, 2.0, 3.0], + 'rising': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'groove': [0.0, 0.25, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5], + 'filtered': [0.0, 2.0], + 'fading': [0.0], + 'minimal': [0.0], + 'absent': [], + }, + 'top_loop': { + 'minimal': [0.25, 1.25, 2.25, 3.25], + 'energy': [0.0, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 2.25, 2.5, 3.0, 3.25, 3.5], + 'building': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'full': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'layered': [0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.25, 2.5, 2.75, 3.25, 3.5, 3.75], + 'filtered': [0.5, 1.5, 2.5, 3.5], + 'fading': [0.5, 2.5], + 'hint': [1.5, 3.5], + 'absent': [], + }, + 'snare_fill': { + 'rolling': [2.0, 2.125, 2.25, 2.375, 2.5, 2.625, 2.75, 2.875, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'tension': [3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'drop_hit': [0.0], + 'fill': [3.0, 3.25, 3.5, 3.75], + 'end_fill': [0.0, 0.25, 0.5, 0.75], + 'absent': [], + }, + 'tom_fill': { + 'rising': [3.0, 3.2, 3.4, 3.6, 3.8], + 'fill': [3.0, 3.125, 3.25, 3.375, 3.5], + 'drop_hit': [0.0], + 'tension': [3.5, 3.625, 3.75, 3.875], + 'end_fill': [0.0, 0.2, 0.4, 0.6], + 'absent': [], + }, +} + +# Section-specific bass variants - EXPANDED +BASS_SECTION_VARIANTS = { + 'intro': ['subtle', 'hint', 'foreshadow', 'ghost', 'minimal'], + 'build': ['rising', 'tension', 'anticipate', 'building', 'pressure'], + 'drop': ['full', 'punch', 'groove', 'deep', 'impact', 'energy', 'rolling'], + 'break': ['sparse', 'minimal', 'atmos', 'filtered', 'foreshadow'], + 'outro': ['fading', 'minimal', 'subtle', 'strip_down'], +} + +# Expanded bass pattern templates (relative positions in 4-bar cycle) +BASS_PATTERN_BANKS = { + 'anchor': { + 'positions': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.5, 0.5, 0.5], + 'style': 'root_heavy' + }, + 'subtle': { + 'positions': [0.0, 2.0], + 'durations': [0.3, 0.3], + 'style': 'minimal' + }, + 'hint': { + 'positions': [0.0, 3.5], + 'durations': [0.25, 0.25], + 'style': 'foreshadow' + }, + 'foreshadow': { + 'positions': [0.0, 1.0, 3.0, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3], + 'style': 'building' + }, + 'ghost': { + 'positions': [0.5, 2.5], + 'durations': [0.2, 0.2], + 'style': 'minimal' + }, + 'rising': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5, 0.4], + 'style': 'ascending' + }, + 'tension': { + 'positions': [0.0, 0.75, 1.5, 2.25, 3.0, 3.5], + 'durations': [0.5, 0.25, 0.5, 0.25, 0.5, 0.3], + 'style': 'syncopated' + }, + 'anticipate': { + 'positions': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5], + 'durations': [0.5, 0.5, 0.4, 0.2, 0.4, 0.2, 0.4], + 'style': 'building' + }, + 'building': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.3, 0.2, 0.3, 0.2], + 'style': 'ascending' + }, + 'pressure': { + 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.3, 0.2, 0.3, 0.2, 0.4, 0.4, 0.4, 0.4, 0.3, 0.2, 0.3, 0.2], + 'style': 'intense' + }, + 'full': { + 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.5, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], + 'style': 'groove' + }, + 'punch': { + 'positions': [0.0, 0.25, 1.0, 2.0, 3.0], + 'durations': [0.6, 0.2, 0.5, 0.5, 0.5], + 'style': 'punchy' + }, + 'groove': { + 'positions': [0.0, 0.25, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.5], + 'durations': [0.4, 0.2, 0.3, 0.4, 0.3, 0.4, 0.3, 0.4, 0.3], + 'style': 'syncopated' + }, + 'deep': { + 'positions': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.8, 0.8, 0.8, 0.8], + 'style': 'sub' + }, + 'impact': { + 'positions': [0.0, 0.5, 1.5, 2.0, 3.0, 3.5], + 'durations': [0.6, 0.4, 0.3, 0.5, 0.5, 0.4], + 'style': 'punchy' + }, + 'energy': { + 'positions': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.25, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], + 'style': 'driving' + }, + 'rolling': { + 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'durations': [0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15], + 'style': 'rolling' + }, + 'sparse': { + 'positions': [0.0, 2.0], + 'durations': [0.4, 0.4], + 'style': 'minimal' + }, + 'minimal': { + 'positions': [0.0], + 'durations': [0.3], + 'style': 'hint' + }, + 'atmos': { + 'positions': [0.0, 3.0], + 'durations': [0.6, 0.4], + 'style': 'atmospheric' + }, + 'filtered': { + 'positions': [0.0, 1.5, 2.5], + 'durations': [0.4, 0.3, 0.3], + 'style': 'filtered' + }, + 'fading': { + 'positions': [0.0, 2.0], + 'durations': [0.5, 0.3], + 'style': 'decay' + }, + 'strip_down': { + 'positions': [0.0], + 'durations': [0.25], + 'style': 'minimal' + }, + 'bounce': { + 'positions': [0.0, 0.5, 1.5, 2.0, 2.5, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.4, 0.3, 0.4], + 'style': 'bouncy' + }, + 'syncopated': { + 'positions': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], + 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2], + 'style': 'offbeat' + }, +} + +# Pattern variant diversity memory - track used variants across generations +_pattern_variant_memory: Dict[str, Dict[str, int]] = { + 'drum': {}, + 'bass': {}, + 'melodic': {}, +} + +def _get_pattern_variant_penalty(category: str, variant: str) -> float: + """Get penalty for a pattern variant based on cross-generation usage.""" + if variant in _pattern_variant_memory.get(category, {}): + count = _pattern_variant_memory[category].get(variant, 0) + return min(0.4, count * 0.08) + return 0.0 + +def _record_pattern_variant_usage(category: str, variant: str) -> None: + """Record that a pattern variant was used.""" + if category not in _pattern_variant_memory: + _pattern_variant_memory[category] = {} + _pattern_variant_memory[category][variant] = _pattern_variant_memory[category].get(variant, 0) + 1 + +def _decay_pattern_variant_memory() -> None: + """Decay pattern variant memory to allow reuse after generations.""" + for category in _pattern_variant_memory: + for variant in list(_pattern_variant_memory[category].keys()): + _pattern_variant_memory[category][variant] = max(0, _pattern_variant_memory[category][variant] - 1) + if _pattern_variant_memory[category][variant] <= 0: + del _pattern_variant_memory[category][variant] + +def reset_pattern_variant_memory() -> None: + """Reset all pattern variant memory.""" + global _pattern_variant_memory + _pattern_variant_memory = {'drum': {}, 'bass': {}, 'melodic': {}} + +# Expanded fill patterns for section transitions +FILL_PATTERNS = { + 'drum_fill_4bar': { + 'roles': ['snare', 'kick', 'hat'], + 'pattern': { + 'snare': [3.0, 3.25, 3.5, 3.75], + 'kick': [3.5], + 'hat': [3.0, 3.5] + }, + 'velocities': {'snare': 100, 'kick': 90, 'hat': 70} + }, + 'drum_fill_2bar': { + 'roles': ['snare', 'hat'], + 'pattern': { + 'snare': [1.5, 1.75], + 'hat': [1.5] + }, + 'velocities': {'snare': 95, 'hat': 65} + }, + 'snare_roll': { + 'roles': ['snare'], + 'pattern': { + 'snare': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] + }, + 'velocities': {'snare': 85} + }, + 'hat_open_build': { + 'roles': ['hat_open'], + 'pattern': { + 'hat_open': [0.0, 0.5, 1.0, 1.5, 2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875] + }, + 'velocities': {'hat_open': 75} + }, + 'kick_drop': { + 'roles': ['kick'], + 'pattern': { + 'kick': [0.0] + }, + 'velocities': {'kick': 127} + }, + 'crash_impact': { + 'roles': ['crash'], + 'pattern': { + 'crash': [0.0] + }, + 'velocities': {'crash': 100} + }, + 'snare_roll_build': { + 'roles': ['snare', 'hat'], + 'pattern': { + 'snare': [2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], + 'hat': [2.0, 2.5, 3.0, 3.5] + }, + 'velocities': {'snare': 88, 'hat': 70} + }, + 'tom_build': { + 'roles': ['tom_fill'], + 'pattern': { + 'tom_fill': [2.0, 2.2, 2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8] + }, + 'velocities': {'tom_fill': 90} + }, + 'full_impact': { + 'roles': ['kick', 'snare', 'crash'], + 'pattern': { + 'kick': [0.0], + 'snare': [0.0, 0.25], + 'crash': [0.0] + }, + 'velocities': {'kick': 127, 'snare': 110, 'crash': 105} + }, + 'hat_tension': { + 'roles': ['hat_closed'], + 'pattern': { + 'hat_closed': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] + }, + 'velocities': {'hat_closed': 72} + }, + 'percussion_fill': { + 'roles': ['perc'], + 'pattern': { + 'perc': [0.5, 0.75, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5] + }, + 'velocities': {'perc': 78} + }, + 'minimal_drop': { + 'roles': ['kick'], + 'pattern': { + 'kick': [0.0] + }, + 'velocities': {'kick': 120} + }, + 'build_tension': { + 'roles': ['snare', 'hat_closed', 'kick'], + 'pattern': { + 'snare': [2.5, 2.75, 3.0, 3.25, 3.5, 3.75], + 'hat_closed': [2.0, 2.5, 3.0, 3.5], + 'kick': [0.0] + }, + 'velocities': {'snare': 92, 'hat_closed': 68, 'kick': 95} + }, + 'outro_fade': { + 'roles': ['hat_closed', 'perc'], + 'pattern': { + 'hat_closed': [0.0, 0.5, 1.0], + 'perc': [0.25, 0.75, 1.25] + }, + 'velocities': {'hat_closed': 80, 'perc': 70} + }, +} + +# Expanded transition events between sections +TRANSITION_EVENTS = { + ('intro', 'build'): ['hat_tension', 'hat_open_build'], + ('build', 'drop'): ['full_impact', 'crash_impact', 'kick_drop', 'snare_roll_build'], + ('drop', 'break'): ['drum_fill_4bar', 'percussion_fill'], + ('break', 'build'): ['hat_tension', 'hat_open_build'], + ('break', 'drop'): ['crash_impact', 'kick_drop', 'full_impact'], + ('drop', 'outro'): ['drum_fill_2bar', 'outro_fade'], + ('outro', 'end'): ['minimal_drop'], +} + +# Rules for preventing transition overcrowding +TRANSITION_DENSITY_RULES = { + # Max fills per section kind + 'max_fills_by_section': { + 'intro': 1, # Minimal fills in intro + 'build': 3, # More fills for tension + 'drop': 2, # Moderate fills + 'break': 2, # Sparse + 'outro': 1, # Minimal + }, + + # Events that should not stack together + 'exclusive_events': [ + {'crash_impact', 'kick_drop'}, # Don't stack impact events + {'drum_fill_4bar', 'snare_roll'}, # Choose one drum fill + ], + + # Minimum distance between same-type fills (in beats) + 'min_distance_same_type': { + 'crash_impact': 8.0, + 'kick_drop': 16.0, + 'snare_roll': 4.0, + } +} + +# Section-specific melodic variants - EXPANDED +MELODIC_SECTION_VARIANTS = { + 'intro': ['subtle', 'foreshadow', 'atmospheric', 'ghost', 'hint'], + 'build': ['rising', 'tension', 'anticipate', 'building', 'energy'], + 'drop': ['hook', 'anthem', 'full', 'punchy', 'impact', 'driving'], + 'break': ['sparse', 'minimal', 'ethereal', 'filtered', 'atmospheric'], + 'outro': ['fading', 'echo', 'minimal', 'strip_down', 'decay'], +} + +# Expanded melodic pattern templates +MELODIC_PATTERN_BANKS = { + 'motif': { + 'intervals': [0, 4, 7, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5], + 'durations': [0.4, 0.3, 0.4, 0.3], + 'style': 'repeating' + }, + 'subtle': { + 'intervals': [0, 0], + 'rhythm': [0.0, 2.0], + 'durations': [0.3, 0.3], + 'style': 'minimal' + }, + 'foreshadow': { + 'intervals': [0, 4, 0], + 'rhythm': [0.0, 1.0, 3.5], + 'durations': [0.4, 0.3, 0.5], + 'style': 'hint' + }, + 'atmospheric': { + 'intervals': [0, 2, 4, 5, 7], + 'rhythm': [0.0, 0.8, 1.6, 2.4, 3.2], + 'durations': [0.8, 0.7, 0.6, 0.5, 0.4], + 'style': 'pad' + }, + 'ghost': { + 'intervals': [0, 7], + 'rhythm': [0.5, 2.5], + 'durations': [0.2, 0.2], + 'style': 'minimal' + }, + 'hint': { + 'intervals': [0, 5], + 'rhythm': [0.0, 3.0], + 'durations': [0.25, 0.25], + 'style': 'minimal' + }, + 'rising': { + 'intervals': [0, 2, 4, 5, 7, 9, 11, 12], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.35, 0.4, 0.35, 0.4, 0.35, 0.5, 0.4], + 'style': 'ascending' + }, + 'tension': { + 'intervals': [0, 1, 0, 1, 2, 1, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'durations': [0.3, 0.2, 0.3, 0.2, 0.3, 0.2, 0.5], + 'style': 'chromatic' + }, + 'anticipate': { + 'intervals': [0, 4, 7, 9, 12], + 'rhythm': [0.0, 1.0, 2.0, 3.0, 3.75], + 'durations': [0.5, 0.4, 0.5, 0.3, 0.5], + 'style': 'buildup' + }, + 'building': { + 'intervals': [0, 2, 4, 5, 7, 9, 11], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.75, 3.5], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], + 'style': 'ascending' + }, + 'energy': { + 'intervals': [0, 4, 7, 9, 12, 14], + 'rhythm': [0.0, 0.25, 0.75, 1.25, 2.0, 2.75], + 'durations': [0.3, 0.25, 0.3, 0.25, 0.4, 0.5], + 'style': 'driving' + }, + 'hook': { + 'intervals': [0, 4, 7, 4, 0, 4, 7, 12], + 'rhythm': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75], + 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.3], + 'style': 'catchy' + }, + 'anthem': { + 'intervals': [0, 4, 7, 12, 11, 7, 4, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.4, 0.4, 0.4, 0.5, 0.4, 0.4, 0.4, 0.5], + 'style': 'big' + }, + 'full': { + 'intervals': [0, 4, 7, 5, 4, 2, 0], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], + 'style': 'melodic' + }, + 'punchy': { + 'intervals': [0, 7, 0, 12], + 'rhythm': [0.0, 0.25, 0.5, 0.75], + 'durations': [0.15, 0.15, 0.15, 0.2], + 'style': 'staccato' + }, + 'impact': { + 'intervals': [0, 5, 7, 12, 7, 5], + 'rhythm': [0.0, 0.5, 0.75, 1.5, 2.25, 3.0], + 'durations': [0.4, 0.25, 0.3, 0.5, 0.3, 0.4], + 'style': 'driving' + }, + 'driving': { + 'intervals': [0, 4, 7, 4, 0, 4, 5, 7], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + 'durations': [0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.4], + 'style': 'repeating' + }, + 'sparse': { + 'intervals': [0, 7], + 'rhythm': [0.0, 2.0], + 'durations': [0.4, 0.4], + 'style': 'minimal' + }, + 'minimal': { + 'intervals': [0], + 'rhythm': [0.0], + 'durations': [0.3], + 'style': 'single' + }, + 'ethereal': { + 'intervals': [0, 7, 12, 7], + 'rhythm': [0.0, 1.5, 2.5, 3.5], + 'durations': [1.0, 0.8, 1.0, 0.8], + 'style': 'pad' + }, + 'filtered': { + 'intervals': [0, 4, 7, 5], + 'rhythm': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.4, 0.5, 0.4], + 'style': 'filtered' + }, + 'fading': { + 'intervals': [0, 4, 0], + 'rhythm': [0.0, 1.0, 2.0], + 'durations': [0.5, 0.4, 0.3], + 'style': 'decay' + }, + 'echo': { + 'intervals': [0, 0, 0], + 'rhythm': [0.0, 0.5, 1.0], + 'durations': [0.3, 0.25, 0.2], + 'style': 'repeat' + }, + 'response': { + 'intervals': [7, 4, 0], + 'rhythm': [0.5, 1.5, 2.5], + 'durations': [0.3, 0.3, 0.4], + 'style': 'call_response' + }, + 'lift': { + 'intervals': [0, 4, 7, 12, 14, 16], + 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5], + 'durations': [0.3, 0.3, 0.3, 0.4, 0.3, 0.4], + 'style': 'ascending' + }, + 'strip_down': { + 'intervals': [0], + 'rhythm': [0.0], + 'durations': [0.25], + 'style': 'minimal' + }, + 'decay': { + 'intervals': [0, 7, 5, 3], + 'rhythm': [0.0, 1.0, 2.0, 3.0], + 'durations': [0.5, 0.4, 0.3, 0.2], + 'style': 'descending' + }, + 'call_response': { + 'intervals': [0, 4, 7, 0, 7, 4], + 'rhythm': [0.0, 0.25, 0.5, 1.5, 2.0, 2.5], + 'durations': [0.25, 0.2, 0.3, 0.35, 0.25, 0.3], + 'style': 'call_response' + }, +} + +# ============================================================================= +# MASTER CHAIN AUTOMATION TARGETS +# ============================================================================= + + +@dataclass +class StyleConfig: + """Configuración de estilo musical""" + genre: str + bpm: float + key: str + scale: str + density: str # minimal, normal, busy + complexity: str # simple, moderate, complex + + +class SongGenerator: + """Generador de configuraciones y patrones musicales""" + + def __init__(self): + self.logger = logging.getLogger("SongGenerator") + self._current_generation_profile = { + 'name': 'default', + 'seed': 0, + 'drum_tightness': 1.0, + 'bass_motion': 'locked', + 'melodic_motion': 'restrained', + 'pan_width': 0.12, + 'fx_bias': 1.0, + } + # Track style adjustments and calibrated volumes for this generation + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + # Tracking for ROLE_GAIN_CALIBRATION overrides + self._gain_calibration_overrides_count = 0 + self._peak_reductions_count = 0 + self._master_profile_used = 'default' + + # ========================================================================= + # UTILIDADES MUSICALES + # ========================================================================= + + def note_name_to_midi(self, note_name: str, octave: int = 3) -> int: + """Convierte nombre de nota a número MIDI""" + note_name = note_name.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') + note_name = note_name.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') + + try: + note_idx = NOTE_NAMES.index(note_name.upper()) + return (octave + 1) * 12 + note_idx + except ValueError: + return 60 # Default C4 + + def midi_to_note_name(self, midi_note: int) -> tuple: + """Convierte MIDI a (nota, octava)""" + octave = (midi_note // 12) - 1 + note_name = NOTE_NAMES[midi_note % 12] + return note_name, octave + + def get_scale_notes(self, root_note: Union[int, str], scale_name: str = 'minor') -> List[int]: + """Obtiene las notas de una escala""" + if isinstance(root_note, str): + root_midi = self.note_name_to_midi(root_note) + else: + root_midi = root_note + + scale_intervals = SCALES.get(scale_name, SCALES['minor']) + return [root_midi + interval for interval in scale_intervals] + + def quantize_to_scale(self, note: int, scale_notes: List[int]) -> int: + """Cuantiza una nota a la escala más cercana""" + if note in scale_notes: + return note + return min(scale_notes, key=lambda x: abs(x - note)) + + # ========================================================================= + # GENERACIÓN DE CONFIGURACIONES + # ========================================================================= + + def _make_note(self, pitch: int, start: float, duration: float, velocity: int) -> Dict[str, Any]: + return { + 'pitch': max(0, min(127, int(pitch))), + 'start': round(float(start), 3), + 'duration': round(max(0.05, float(duration)), 3), + 'velocity': max(1, min(127, int(velocity))), + } + + def _repeat_pattern(self, pattern: List[Dict[str, Any]], total_length: float, pattern_length: float = 4.0) -> List[Dict[str, Any]]: + if not pattern or total_length <= 0 or pattern_length <= 0: + return [] + + notes = [] + repeats = max(1, int(round(total_length / pattern_length))) + for repeat_index in range(repeats): + offset = repeat_index * pattern_length + for note in pattern: + start = float(note['start']) + offset + if start >= total_length: + continue + duration = min(float(note['duration']), total_length - start) + notes.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + return notes + + def _section_rng(self, section: Dict[str, Any], role: str, salt: int = 0) -> random.Random: + base_seed = int(self._current_generation_profile.get('seed', 0)) + section_index = int(section.get('index', 0)) + role_fingerprint = sum((index + 1) * ord(char) for index, char in enumerate(str(role))) + return random.Random(base_seed + (section_index * 1009) + (role_fingerprint * 17) + (salt * 7919)) + + def _clamp_pan(self, value: float) -> float: + return round(max(-1.0, min(1.0, float(value))), 3) + + def _clamp_unit(self, value: float) -> float: + return round(max(0.0, min(1.0, float(value))), 3) + + def _apply_swing(self, notes: List[Dict[str, Any]], amount: float, section_length: float) -> List[Dict[str, Any]]: + if not notes or abs(amount) < 0.001: + return notes + + swung = [] + for note in notes: + start = float(note['start']) + fractional = round(start % 1.0, 3) + if 0.001 < fractional < 0.999: + shift = amount if fractional >= 0.5 else (amount * -0.45) + start = min(max(0.0, start + shift), max(0.0, section_length - 0.05)) + swung.append(self._make_note(note['pitch'], start, note['duration'], note['velocity'])) + swung.sort(key=lambda item: (item['start'], item['pitch'])) + return swung + + def _apply_density_mask(self, notes: List[Dict[str, Any]], section: Dict[str, Any], role: str, + keep_probability: float) -> List[Dict[str, Any]]: + if not notes or keep_probability >= 0.995: + return notes + + rng = self._section_rng(section, role, salt=3) + filtered = [] + for note in notes: + start = float(note['start']) + if abs(start % 1.0) < 0.001: + filtered.append(note) + continue + if rng.random() <= keep_probability: + filtered.append(note) + return filtered or notes[:1] + + def _build_arrangement_profile(self, genre: str, style: str, variant_seed: int) -> Dict[str, Any]: + style_text = "{} {}".format(genre, style).lower() + candidates = [profile for profile in ARRANGEMENT_PROFILES if genre in set(profile.get('genres', ()))] + + if 'latin' in style_text: + candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['swing', 'jackin']] or candidates + elif 'industrial' in style_text: + candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['warehouse', 'festival']] or candidates + + if not candidates: + candidates = list(ARRANGEMENT_PROFILES) + + rng = random.Random(int(variant_seed) + 41) + selected = dict(rng.choice(candidates)) + selected['seed'] = int(variant_seed) + return selected + + def _extend_parallel_sends(self, role: str, sends: Dict[str, Any]) -> Dict[str, Any]: + resolved = dict(sends or {}) + if role in ['kick', 'clap', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'snare_fill', 'tom_fill']: + resolved.setdefault('glue', 0.1) + resolved.setdefault('heat', 0.05) + elif role in ['sub_bass', 'bass', 'stab']: + resolved.setdefault('glue', 0.08) + resolved.setdefault('heat', 0.08) + elif role in ['chords', 'pad', 'pluck', 'arp', 'lead', 'counter', 'vocal']: + resolved.setdefault('glue', 0.04) + elif role in ['reverse_fx', 'riser', 'impact', 'atmos', 'drone', 'crash']: + resolved.setdefault('glue', 0.03) + return resolved + + def _resolve_bus_for_role(self, role: str) -> Optional[str]: + return ROLE_BUS_ASSIGNMENTS.get(str(role or '').strip().lower(), 'music') + + def _get_section_variation(self, role: str, section_kind: str) -> Dict[str, Any]: + """ + Obtiene configuración de variación para un rol y sección. + + Retorna dict con: + - use: bool - si el rol debe usarse en esta sección + - sparse: bool - si usar variante sparse + - full: bool - si usar variante completa + - intensity: float - intensidad de 0 a 1 + - etc. + """ + if role not in SECTION_VARIATION_CONFIG: + return {'use': True, 'intensity': 1.0} + + role_config = SECTION_VARIATION_CONFIG[role] + return role_config.get(section_kind.lower(), {'use': True, 'intensity': 1.0}) + + def _should_vary_role_in_section(self, role: str, section_kind: str) -> bool: + """Determina si un rol debe variar en una sección dada.""" + if role not in SECTION_VARIATION_CONFIG: + return False + + config = self._get_section_variation(role, section_kind) + + # Si tiene clave 'use' explícita + if 'use' in config: + return config['use'] + + # Si tiene variantes específicas + return any(k in config for k in ['sparse', 'full', 'building', 'fading']) + + def _build_mix_bus_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + + buses = [ + { + 'key': 'drums', + 'name': 'DRUM BUS', + 'color': BUS_TRACK_COLORS['drums'], + 'volume': 0.86, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -16.5}}, + {'device': 'Saturator', 'parameters': {'Drive': 1.2}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.3}}, + ], + }, + { + 'key': 'bass', + 'name': 'BASS BUS', + 'color': BUS_TRACK_COLORS['bass'], + 'volume': 0.8, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Saturator', 'parameters': {'Drive': 1.3}}, + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 0.0}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'music', + 'name': 'MUSIC BUS', + 'color': BUS_TRACK_COLORS['music'], + 'volume': 0.8, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -21.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 12800.0, 'Dry/Wet': 0.05}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 1.12}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'vocal', + 'name': 'VOCAL BUS', + 'color': BUS_TRACK_COLORS['vocal'], + 'volume': 0.82, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 0.05}}, + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.05}}, + {'device': 'Utility', 'parameters': {'Gain': 0.2}}, + ], + }, + { + 'key': 'fx', + 'name': 'FX BUS', + 'color': BUS_TRACK_COLORS['fx'], + 'volume': 0.76, + 'pan': 0.0, + 'monitoring': 'in', + 'fx_chain': [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 10200.0, 'Dry/Wet': 0.1}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, + {'device': 'Utility', 'parameters': {'Gain': -0.2}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, + ], + }, + ] + + # ========================================================================= + # Apply BUS_GAIN_CALIBRATION as safe baseline BEFORE profile overrides + # ========================================================================= + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + + def find_device_in_chain(fx_chain, device_type): + for device in fx_chain: + if device.get('device') == device_type: + return device + return None + + for bus in buses: + bus_key = bus.get('key', '') + if bus_key not in BUS_GAIN_CALIBRATION: + continue + + calibration = BUS_GAIN_CALIBRATION[bus_key] + + if 'volume' in calibration: + bus['volume'] = calibration['volume'] + + fx_chain = bus.get('fx_chain', []) + + if 'compressor_threshold' in calibration: + compressor = find_device_in_chain(fx_chain, 'Compressor') + if compressor: + compressor['parameters']['Threshold'] = calibration['compressor_threshold'] + + if 'saturator_drive' in calibration: + saturator = find_device_in_chain(fx_chain, 'Saturator') + if saturator: + saturator['parameters']['Drive'] = calibration['saturator_drive'] + + if 'limiter_gain' in calibration: + limiter = find_device_in_chain(fx_chain, 'Limiter') + if limiter: + limiter['parameters']['Gain'] = calibration['limiter_gain'] + + if 'utility_gain' in calibration: + for device in fx_chain: + if device.get('device') == 'Utility': + if 'Gain' in device.get('parameters', {}): + device['parameters']['Gain'] = calibration['utility_gain'] + break + elif 'Stereo Width' not in device.get('parameters', {}): + device['parameters']['Gain'] = calibration['utility_gain'] + break + + # ========================================================================= + # Profile-specific overrides ON TOP of calibrated baselines + # ========================================================================= + if profile_name == 'warehouse': + buses[0]['name'] = 'DRUM BUNKER' + buses[0]['fx_chain'][1]['parameters']['Drive'] = 3.1 + buses[1]['name'] = 'LOW END BUS' + buses[1]['fx_chain'][0]['parameters']['Drive'] = 4.0 + buses[2]['fx_chain'][1]['parameters']['Frequency'] = 11200.0 + elif profile_name == 'festival': + buses[2]['name'] = 'MUSIC WIDE' + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.14 + buses[3]['name'] = 'VOCAL TAIL' + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[4]['name'] = 'FX WASH' + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 + elif profile_name == 'swing': + buses[0]['name'] = 'DRUM POCKET' + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -13.5 + buses[3]['name'] = 'VOCAL SLAP' + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 + elif profile_name == 'jackin': + buses[0]['name'] = 'DRUM CLUB' + buses[2]['name'] = 'MUSIC JACK' + buses[3]['name'] = 'VOX CLUB' + buses[4]['name'] = 'FX JAM' + elif profile_name == 'tech-house-club': + # Club-oriented tech-house with punchy drums and latin vocal treatment + buses[0]['name'] = 'DRUM CLUB' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -15.5 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 2.2 + buses[1]['name'] = 'BASS TUBE' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.5 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -17.0 + buses[2]['name'] = 'MUSIC JACK' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.16 + buses[3]['name'] = 'VOCAL LATIN BUS' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.10 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[4]['name'] = 'FX JAM' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 + elif profile_name == 'tech-house-deep': + # Minimal deep tech-house with subtle processing + buses[0]['name'] = 'DRUM DEEP' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -18.0 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 0.8 + buses[1]['name'] = 'SUB DEEP' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 1.0 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -20.0 + buses[2]['name'] = 'ATMOS DEEP' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][0]['parameters']['Threshold'] = -24.0 + buses[2]['fx_chain'][1]['parameters']['Frequency'] = 10200.0 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.08 + buses[3]['name'] = 'VOX DEEP' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.04 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.06 + buses[4]['name'] = 'FX DEEP' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.08 + elif profile_name == 'tech-house-funky': + # Groovy tech-house with wide stereo and bouncy feel + buses[0]['name'] = 'DRUM GROOVE' + buses[0]['volume'] = 0.95 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.5 + buses[0]['fx_chain'][1]['parameters']['Drive'] = 1.8 + buses[1]['name'] = 'BASS FUNK' + buses[1]['volume'] = 0.95 + buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.0 + buses[1]['fx_chain'][1]['parameters']['Threshold'] = -16.5 + buses[2]['name'] = 'MUSIC GROOVE' + buses[2]['volume'] = 0.95 + buses[2]['fx_chain'][0]['parameters']['Threshold'] = -20.0 + buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.20 + buses[3]['name'] = 'VOCAL FUNK' + buses[3]['volume'] = 0.95 + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.10 + buses[4]['name'] = 'FX SWING' + buses[4]['volume'] = 0.95 + buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.16 + + if 'industrial' in style_text: + buses[0]['fx_chain'][1]['parameters']['Drive'] = max( + 3.4, + float(buses[0]['fx_chain'][1]['parameters'].get('Drive', 2.2)), + ) + buses[1]['fx_chain'][0]['parameters']['Drive'] = max( + 4.2, + float(buses[1]['fx_chain'][0]['parameters'].get('Drive', 3.2)), + ) + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + buses[3]['name'] = 'VOCAL LATIN BUS' + buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.14 + buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 + buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.0 + + # ========================================================================= + # Apply STYLE_GAIN_ADJUSTMENTS as multipliers AFTER profile overrides + # ========================================================================= + for style_key, adjustments in STYLE_GAIN_ADJUSTMENTS.items(): + if style_key.lower() in style_text: + self._style_adjustments_applied.append(style_key) + + # Apply bus volume factors + if 'drums_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'drums': + bus['volume'] = bus.get('volume', 0.8) * adjustments['drums_bus_volume_factor'] + + if 'bass_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'bass': + bus['volume'] = bus.get('volume', 0.8) * adjustments['bass_bus_volume_factor'] + + if 'vocal_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'vocal': + bus['volume'] = bus.get('volume', 0.8) * adjustments['vocal_bus_volume_factor'] + + if 'music_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'music': + bus['volume'] = bus.get('volume', 0.8) * adjustments['music_bus_volume_factor'] + + if 'fx_bus_volume_factor' in adjustments: + for bus in buses: + if bus.get('key') == 'fx': + bus['volume'] = bus.get('volume', 0.8) * adjustments['fx_bus_volume_factor'] + + # Apply saturator_drive_factor to all bus saturators + if 'saturator_drive_factor' in adjustments: + for bus in buses: + fx_chain = bus.get('fx_chain', []) + saturator = find_device_in_chain(fx_chain, 'Saturator') + if saturator and 'Drive' in saturator.get('parameters', {}): + saturator['parameters']['Drive'] = ( + saturator['parameters']['Drive'] * adjustments['saturator_drive_factor'] + ) + + # Apply limiter_gain_factor to all bus limiters + if 'limiter_gain_factor' in adjustments: + for bus in buses: + fx_chain = bus.get('fx_chain', []) + limiter = find_device_in_chain(fx_chain, 'Limiter') + if limiter and 'Gain' in limiter.get('parameters', {}): + limiter['parameters']['Gain'] = ( + limiter['parameters']['Gain'] * adjustments['limiter_gain_factor'] + ) + + # Store final calibrated bus volumes + for bus in buses: + bus_key = bus.get('key', '') + if bus_key: + self._calibrated_bus_volumes[bus_key] = bus.get('volume', 0.0) + + # RCA Fix: Automatic Makeup and Output gain compensation + for bus in buses: + for device in bus.get('fx_chain', []): + device_type = device.get('device') + params = device.get('parameters', {}) + if device_type == 'Compressor' and 'Threshold' in params: + params['Makeup'] = round(abs(params['Threshold']) * 0.25, 1) + elif device_type == 'Saturator' and 'Drive' in params: + params['Output'] = round(-params['Drive'] * 1.5, 1) + + return buses + + def _build_return_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + returns = [ + { + 'name': 'MCP SPACE', + 'send_key': 'space', + 'color': 56, + 'device_chain': [{'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}], + 'volume': 0.76, + }, + { + 'name': 'MCP ECHO', + 'send_key': 'echo', + 'color': 44, + 'device_chain': [{'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}], + 'volume': 0.72, + }, + { + 'name': 'MCP HEAT', + 'send_key': 'heat', + 'color': 12, + 'device_chain': [ + {'device': 'Saturator', 'parameters': {'Drive': 4.5}}, + {'device': 'Compressor', 'parameters': {'Threshold': -16.0}}, + ], + 'volume': 0.62, + }, + { + 'name': 'MCP GLUE', + 'send_key': 'glue', + 'color': 58, + 'device_chain': [ + {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, + {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, + ], + 'volume': 0.68, + }, + ] + + if profile_name == 'warehouse': + returns[0]['name'] = 'MCP BUNKER' + returns[0]['device_chain'] = [ + {'device': 'Auto Filter', 'parameters': {'Frequency': 7200.0, 'Dry/Wet': 0.22}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, + ] + returns[1]['name'] = 'MCP DUB' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8200.0, 'Dry/Wet': 0.14}}, + ] + returns[2]['device_chain'][0]['parameters']['Drive'] = 5.5 + returns[2]['volume'] = 0.66 + elif profile_name == 'festival': + returns[0]['name'] = 'MCP WIDE' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Utility', 'parameters': {'Stereo Width': 1.14}}, + ] + returns[1]['name'] = 'MCP TAIL' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, + ] + returns[0]['volume'] = 0.72 + returns[1]['volume'] = 0.68 + elif profile_name == 'swing': + returns[0]['name'] = 'MCP ROOM' + returns[1]['name'] = 'MCP SLAP' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.1}}, + ] + returns[2]['volume'] = 0.58 + elif profile_name == 'jackin': + returns[0]['name'] = 'MCP CLUB' + returns[1]['name'] = 'MCP SWING' + returns[2]['device_chain'][0]['parameters']['Drive'] = 3.8 + returns[3]['volume'] = 0.72 + elif profile_name == 'tech-house-club': + # Short reverb, mono delay, wide FX for club tech-house + returns[0]['name'] = 'REVERB SHORT' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.6}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8400.0, 'Dry/Wet': 0.08}}, + ] + returns[0]['volume'] = 0.70 + returns[1]['name'] = 'DELAY MONO' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.0}}, + {'device': 'Utility', 'parameters': {'Width': 0.0}}, + ] + returns[1]['volume'] = 0.68 + returns[2]['name'] = 'DRIVE HOT' + returns[2]['device_chain'][0]['parameters']['Drive'] = 4.0 + returns[2]['volume'] = 0.64 + returns[3]['name'] = 'GLUE BUS' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -16.5 + returns[3]['volume'] = 0.70 + elif profile_name == 'tech-house-deep': + # Deep minimal returns with subtle processing + returns[0]['name'] = 'REVERB DEEP' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 1.2}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 6200.0, 'Dry/Wet': 0.12}}, + ] + returns[0]['volume'] = 0.72 + returns[1]['name'] = 'DELAY DEEP' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Feedback': 0.45}}, + ] + returns[1]['volume'] = 0.64 + returns[2]['name'] = 'SATURATE DEEP' + returns[2]['device_chain'][0]['parameters']['Drive'] = 2.5 + returns[2]['volume'] = 0.56 + returns[3]['name'] = 'GLUE MINIMAL' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -20.0 + returns[3]['volume'] = 0.62 + elif profile_name == 'tech-house-funky': + # Groovy returns with modulation and swing + returns[0]['name'] = 'REVERB GROOVE' + returns[0]['device_chain'] = [ + {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.8}}, + {'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.08}}, + ] + returns[0]['volume'] = 0.74 + returns[1]['name'] = 'DELAY GROOVE' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.4, 'Feedback': 0.35}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 8000.0, 'Dry/Wet': 0.1}}, + ] + returns[1]['volume'] = 0.70 + returns[2]['name'] = 'DRIVE FUNK' + returns[2]['device_chain'][0]['parameters']['Drive'] = 3.2 + returns[2]['device_chain'].append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) + returns[2]['volume'] = 0.60 + returns[3]['name'] = 'GLUE SWING' + returns[3]['device_chain'][0]['parameters']['Threshold'] = -15.5 + returns[3]['volume'] = 0.72 + + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + returns[1]['name'] = 'MCP VOX ECHO' + returns[1]['device_chain'] = [ + {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, + {'device': 'Auto Filter', 'parameters': {'Frequency': 10800.0, 'Dry/Wet': 0.12}}, + ] + returns[0]['volume'] = max(0.68, float(returns[0]['volume']) - 0.04) + if 'industrial' in style_text: + returns[2]['name'] = 'MCP DRIVE' + returns[2]['device_chain'][0]['parameters']['Drive'] = max( + 4.8, + float(returns[2]['device_chain'][0]['parameters'].get('Drive', 4.5)) + ) + returns[3]['name'] = 'MCP BUS' + + return returns + + def _build_master_blueprint( + self, + profile: Dict[str, Any], + genre: str, + style: str, + reference_resolution: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + style_text = f"{genre} {style}".lower() + profile_name = str(profile.get('name', 'default')).lower() + reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + + # Start with default calibration values + calibration = dict(MASTER_CALIBRATION.get('default', {})) + + # Find matching profile (case-insensitive, partial match) + matched_profile = 'default' + profile_name_lower = profile_name.lower() + for cal_key in MASTER_CALIBRATION.keys(): + if cal_key.lower() in profile_name_lower or profile_name_lower in cal_key.lower(): + # Merge profile-specific values over defaults + profile_cal = MASTER_CALIBRATION[cal_key] + calibration.update(profile_cal) + matched_profile = cal_key + break + + # Track which profile was used + self._master_profile_used = matched_profile + + # Build master with calibrated values + # Master chain: Utility (gain staging) -> Saturator (color) -> Compressor (glue) -> Limiter (ceiling) + # Target: -1dB peak before limiter, -0.3dBFS ceiling after limiter + master = { + 'volume': calibration.get('volume', 0.85), + 'device_chain': [ + { + 'device': 'Utility', + 'parameters': { + 'Gain': calibration.get('utility_gain', -0.5), + 'Stereo Width': calibration.get('stereo_width', 1.04), + } + }, + { + 'device': 'Saturator', + 'parameters': {'Drive': calibration.get('saturator_drive', 0.12)} + }, + { + 'device': 'Compressor', + 'parameters': { + 'Ratio': calibration.get('compressor_ratio', 0.50), + 'Attack': calibration.get('compressor_attack', 0.30), + 'Release': calibration.get('compressor_release', 0.20), + } + }, + { + 'device': 'Limiter', + 'parameters': { + 'Gain': calibration.get('limiter_gain', 0.8), + 'Ceiling': calibration.get('limiter_ceiling', -0.3), + } + }, + ], + } + + # Apply style-based limiter_gain_factor from STYLE_GAIN_ADJUSTMENTS + for style_key, style_adj in STYLE_GAIN_ADJUSTMENTS.items(): + if style_key.lower() in style_text: + limiter_factor = style_adj.get('limiter_gain_factor') + if limiter_factor is not None: + master['device_chain'][3]['parameters']['Gain'] *= limiter_factor + break + + if 'industrial' in style_text: + master['device_chain'][1]['parameters']['Drive'] = max( + 0.8, + float(master['device_chain'][1]['parameters'].get('Drive', 0.3)) + ) + master['device_chain'][2]['parameters']['Ratio'] = max( + 0.7, + float(master['device_chain'][2]['parameters'].get('Ratio', 0.62)) + ) + + if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): + master['device_chain'][0]['parameters']['Stereo Width'] = max( + 1.14, + float(master['device_chain'][0]['parameters'].get('Stereo Width', 1.1)) + ) + master['device_chain'][3]['parameters']['Gain'] = max( + 0.1, + float(master['device_chain'][3]['parameters'].get('Gain', 0.0)) + ) + + return master + + def _apply_role_gain_calibration(self, role: str, base_volume: float) -> Dict[str, float]: + """ + Apply ROLE_GAIN_CALIBRATION to a role's volume. + + Args: + role: The role name (e.g., 'kick', 'bass', 'clap') + base_volume: The base volume from ROLE_MIX + + Returns: + Dict with 'volume' and optionally 'saturator_drive' if calibrated + """ + if role not in ROLE_GAIN_CALIBRATION: + return {'volume': base_volume} + + calibration = ROLE_GAIN_CALIBRATION[role] + calibrated_volume = float(calibration.get('volume', base_volume)) + + # Apply peak_reduction if present + peak_reduction = calibration.get('peak_reduction', 0.0) + if peak_reduction > 0: + calibrated_volume *= (1.0 - float(peak_reduction)) + self._peak_reductions_count += 1 + + result = {'volume': round(max(0.0, min(1.0, calibrated_volume)), 3)} + + # Include saturator_drive if present in calibration + if 'saturator_drive' in calibration: + result['saturator_drive'] = float(calibration['saturator_drive']) + + self._gain_calibration_overrides_count += 1 + + return result + + def _shape_mix_profile(self, role: str, mix_profile: Dict[str, Any], profile: Dict[str, Any], style: str) -> Dict[str, Any]: + shaped = { + 'volume': float(mix_profile.get('volume', 0.72)), + 'pan': float(mix_profile.get('pan', 0.0)), + 'sends': dict(mix_profile.get('sends', {})), + } + + # Apply ROLE_GAIN_CALIBRATION if available - overrides base volume + calibration = self._apply_role_gain_calibration(role, shaped['volume']) + if calibration.get('volume') is not None: + shaped['volume'] = calibration['volume'] + if calibration.get('saturator_drive') is not None: + shaped['saturator_drive'] = calibration['saturator_drive'] + + profile_name = str(profile.get('name', 'default')).lower() + pan_width = float(profile.get('pan_width', 0.16) or 0.16) + style_text = str(style or '').lower() + + if role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pluck', 'arp', 'counter', 'vocal']: + shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * (1.0 + pan_width))) + + if profile_name == 'warehouse': + if role in ['kick', 'bass', 'sub_bass']: + shaped['volume'] *= 1.03 + if role in ['pad', 'drone', 'atmos']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.88 + if role in ['reverse_fx', 'riser', 'impact']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.08) + elif profile_name == 'festival': + if role in ['lead', 'chords', 'pad', 'arp', 'vocal']: + shaped['volume'] *= 1.04 + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.15 + if role in ['kick', 'clap']: + shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.12) + elif profile_name == 'swing': + if role in ['perc', 'top_loop', 'ride', 'vocal', 'pluck']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.14 + if role in ['kick', 'sub_bass']: + shaped['volume'] *= 0.98 + elif profile_name == 'jackin': + if role in ['clap', 'perc', 'vocal', 'counter']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.08 + if role in ['top_loop', 'ride']: + shaped['volume'] *= 1.03 + elif profile_name == 'tech-house-club': + # Club-oriented: punchy drums, present vocals, tight bass + if role in ['kick', 'clap']: + shaped['volume'] *= 1.02 + shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.10) + if role in ['bass', 'sub_bass']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.06) + if role in ['vocal', 'counter']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.10 + if role in ['hat_open', 'top_loop', 'ride']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.92 + elif profile_name == 'tech-house-deep': + # Deep minimal: subtle processing, wide stereo + if role in ['kick', 'sub_bass']: + shaped['volume'] *= 0.98 + if role in ['pad', 'drone', 'atmos', 'chords']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.12 + if role in ['perc', 'top_loop']: + shaped['volume'] *= 0.95 + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 0.88 + elif profile_name == 'tech-house-funky': + # Funky groove: wider pan, more echo, bouncy feel + if role in ['perc', 'top_loop', 'ride']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.18 + if role in ['bass', 'sub_bass']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.05) + if role in ['vocal', 'pluck', 'arp']: + shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.08 + if role in ['clap', 'hat_closed']: + shaped['volume'] *= 1.02 + + if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: + shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.12 + shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * 1.08)) + if 'industrial' in style_text and role in ['kick', 'bass', 'stab', 'impact', 'riser']: + shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.09) + + shaped['volume'] = round(max(0.0, min(1.0, shaped['volume'])), 3) + shaped['pan'] = round(max(-1.0, min(1.0, shaped['pan'])), 3) + shaped['sends'] = { + send_key: round(max(0.0, min(1.0, float(send_value))), 3) + for send_key, send_value in shaped['sends'].items() + } + return shaped + + def _shape_role_fx_chain(self, role: str, profile: Dict[str, Any], style: str) -> List[Dict[str, Any]]: + chain = [dict(item) for item in ROLE_FX_CHAINS.get(role, [])] + profile_name = str(profile.get('name', 'default')).lower() + style_text = str(style or '').lower() + + if profile_name == 'warehouse': + if role in ['kick', 'bass', 'stab']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -18.0}}) + if role in ['atmos', 'drone', 'pad']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 7600.0, 'Dry/Wet': 0.14}}) + elif profile_name == 'festival': + if role in ['lead', 'arp', 'vocal']: + chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.1}}) + if role in ['chords', 'pad']: + chain.append({'device': 'Utility', 'parameters': {'Width': 140.0}}) + elif profile_name == 'swing': + if role in ['perc', 'top_loop', 'ride', 'vocal']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) + elif profile_name == 'jackin': + if role in ['clap', 'perc', 'vocal', 'counter']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.5}}) + elif profile_name == 'tech-house-club': + # Club: punchy drums, saturated bass, crisp tops + if role in ['kick', 'clap']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -16.0, 'Attack': 0.02}}) + if role in ['bass', 'sub_bass']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 2.0}}) + if role in ['hat_closed', 'hat_open', 'top_loop']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.12}}) + if role in ['vocal', 'counter']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) + elif profile_name == 'tech-house-deep': + # Deep: subtle saturation, atmospheric processing + if role in ['kick', 'bass']: + chain.append({'device': 'Compressor', 'parameters': {'Threshold': -20.0}}) + if role in ['pad', 'drone', 'atmos']: + chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}) + if role in ['chords', 'pluck']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 9200.0, 'Dry/Wet': 0.08}}) + elif profile_name == 'tech-house-funky': + # Funky: groove-enhancing FX, modulation + if role in ['perc', 'top_loop', 'ride']: + chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.10, 'Ping Pong': 0.3}}) + if role in ['bass', 'sub_bass']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) + if role in ['vocal', 'pluck', 'arp']: + chain.append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) + if role in ['clap', 'hat_closed']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.2}}) + + if 'industrial' in style_text and role in ['kick', 'bass', 'impact', 'riser']: + chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) + if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: + chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 11200.0, 'Dry/Wet': 0.1}}) + + return chain + + def _get_section_drum_variant(self, role: str, section: Dict[str, Any]) -> str: + """Get appropriate drum variant for section and role with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + role_lower = role.lower() + + if role_lower not in DRUM_SECTION_VARIANTS.get(kind, {}): + return 'straight' + + variants = list(DRUM_SECTION_VARIANTS[kind][role_lower]) + valid_variants = [v for v in variants if v in DRUM_PATTERN_BANKS.get(role_lower, {})] + if not valid_variants and role_lower in DRUM_PATTERN_BANKS: + valid_variants = list(DRUM_PATTERN_BANKS[role_lower].keys()) + + if not valid_variants: + return 'straight' + + rng = self._section_rng(section, role, salt=1) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('drum', f'{role_lower}_{v}') + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] + + _record_pattern_variant_usage('drum', f'{role_lower}_{chosen}') + return chosen + + def _generate_drum_pattern_from_bank(self, role: str, variant: str, + section_length: float, + velocity_base: int = 100) -> List[Dict[str, Any]]: + """Generate drum pattern from pattern bank.""" + role_lower = role.lower() + + if role_lower not in DRUM_PATTERN_BANKS: + return [] + + bank = DRUM_PATTERN_BANKS[role_lower] + if variant not in bank: + variant = list(bank.keys())[0] # Fallback to first + + positions = bank[variant] + notes = [] + + # Determine pitch based on role + pitch_map = { + 'kick': 36, 'clap': 39, 'hat_closed': 42, + 'hat_open': 46, 'perc': 50, 'ride': 51 + } + pitch = pitch_map.get(role_lower, 36) + + for pos in positions: + # Repeat pattern for each bar + for bar in range(int(section_length // 4)): + start = pos + (bar * 4.0) + if start < section_length: + # Add slight velocity variation + velocity = max(60, min(127, velocity_base + random.randint(-10, 10))) + duration = 0.1 if role_lower in ['hat_closed', 'hat_open', 'ride'] else 0.15 + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated drum pattern from bank: role={role}, variant={variant}, notes={len(notes)}") + return notes + + def _get_section_bass_variant(self, section: Dict[str, Any]) -> str: + """Get appropriate bass variant for section with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + + if kind not in BASS_SECTION_VARIANTS: + return 'anchor' + + variants = list(BASS_SECTION_VARIANTS[kind]) + valid_variants = [v for v in variants if v in BASS_PATTERN_BANKS] + if not valid_variants: + valid_variants = list(BASS_PATTERN_BANKS.keys()) + + rng = self._section_rng(section, 'bass', salt=2) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('bass', v) + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] if valid_variants else 'anchor' + + _record_pattern_variant_usage('bass', chosen) + return chosen + + def _compute_section_signature(self, section: Dict[str, Any]) -> str: + """Compute a signature for section to detect repetition.""" + section = self._ensure_section_pattern_variants(section) + signature_parts = [] + drum_role_variants = dict(section.get('drum_role_variants') or {}) + + signature_parts.append(f"kick:{drum_role_variants.get('kick', section.get('drum_variant', 'default'))}") + signature_parts.append(f"clap:{drum_role_variants.get('clap', section.get('drum_variant', 'default'))}") + signature_parts.append(f"hat:{drum_role_variants.get('hat_closed', section.get('drum_variant', 'default'))}") + signature_parts.append(f"bass:{section.get('bass_bank_variant', section.get('bass_variant', 'default'))}") + signature_parts.append(f"lead:{section.get('melodic_bank_variant', section.get('melodic_variant', 'default'))}") + signature_parts.append(f"fill:{section.get('transition_fill', 'none')}") + + # Add density and swing + density = section.get('density', 1.0) + swing = section.get('swing', 0.0) + signature_parts.append(f"d:{density:.1f}") + signature_parts.append(f"s:{swing:.2f}") + + return "|".join(signature_parts) + + def _check_section_repetition(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Check and warn about excessive section repetition.""" + signatures = [] + consecutive_same = 0 + max_consecutive = 2 + + for i, section in enumerate(sections): + self._ensure_section_pattern_variants(section) + sig = self._compute_section_signature(section) + + if signatures and signatures[-1] == sig: + consecutive_same += 1 + if consecutive_same >= max_consecutive: + logger.warning("REPETITION: %d consecutive sections with same signature: %s", + consecutive_same + 1, sig) + self._force_section_pattern_variation(section) + sig = self._compute_section_signature(section) + else: + consecutive_same = 0 + + signatures.append(sig) + + return sections + + def _record_section_variant(self, section: Dict[str, Any], role: str, variant: str): + """Record variant used for a role in a section.""" + key = f'{role}_variant' + section[key] = variant + + def _choose_alternate_variant(self, options: List[str], current: Optional[str], rng: random.Random) -> Optional[str]: + ordered: List[str] = [] + for option in options: + if option not in ordered: + ordered.append(option) + if not ordered: + return current + alternatives = [option for option in ordered if option != current] + if not alternatives: + return current or ordered[0] + return rng.choice(alternatives) + + def _ensure_section_pattern_variants(self, section: Dict[str, Any]) -> Dict[str, Any]: + _kind = str(section.get('kind', 'drop')).lower() # noqa: F841 - used by helper methods via section dict + drum_role_variants = dict(section.get('drum_role_variants') or {}) + for role in ['kick', 'clap', 'hat_closed', 'hat_open', 'perc', 'ride']: + if role in drum_role_variants: + continue + variant = self._get_section_drum_variant(role, section) + if variant in DRUM_PATTERN_BANKS.get(role, {}): + drum_role_variants[role] = variant + self._record_section_variant(section, role, variant) + section['drum_role_variants'] = drum_role_variants + + bass_bank_variant = str(section.get('bass_bank_variant', '') or '') + if bass_bank_variant not in BASS_PATTERN_BANKS: + bass_bank_variant = self._get_section_bass_variant(section) + section['bass_bank_variant'] = bass_bank_variant + self._record_section_variant(section, 'bass_bank', str(section.get('bass_bank_variant', 'anchor'))) + + melodic_bank_variant = str(section.get('melodic_bank_variant', '') or '') + if melodic_bank_variant not in MELODIC_PATTERN_BANKS: + melodic_bank_variant = self._get_section_melodic_variant(section) + section['melodic_bank_variant'] = melodic_bank_variant + self._record_section_variant(section, 'melodic_bank', str(section.get('melodic_bank_variant', 'motif'))) + section.setdefault('pattern_variant_ready', True) + return section + + def _force_section_pattern_variation(self, section: Dict[str, Any]) -> Dict[str, Any]: + kind = str(section.get('kind', 'drop')).lower() + self._ensure_section_pattern_variants(section) + drum_role_variants = dict(section.get('drum_role_variants') or {}) + + for role in ['kick', 'clap', 'hat_closed']: + options = DRUM_SECTION_VARIANTS.get(kind, {}).get(role, []) + current = drum_role_variants.get(role) + next_variant = self._choose_alternate_variant(options, current, self._section_rng(section, role, salt=101)) + if next_variant: + drum_role_variants[role] = next_variant + self._record_section_variant(section, role, next_variant) + section['drum_role_variants'] = drum_role_variants + + bass_options = BASS_SECTION_VARIANTS.get(kind, []) + bass_variant = self._choose_alternate_variant( + bass_options, + str(section.get('bass_bank_variant', '') or ''), + self._section_rng(section, 'bass', salt=102), + ) + if bass_variant: + section['bass_bank_variant'] = bass_variant + self._record_section_variant(section, 'bass_bank', bass_variant) + + melodic_options = MELODIC_SECTION_VARIANTS.get(kind, []) + melodic_variant = self._choose_alternate_variant( + melodic_options, + str(section.get('melodic_bank_variant', '') or ''), + self._section_rng(section, 'melodic', salt=103), + ) + if melodic_variant: + section['melodic_bank_variant'] = melodic_variant + self._record_section_variant(section, 'melodic_bank', melodic_variant) + + return section + + def _generate_bass_pattern_from_bank(self, variant: str, key: str, + section_length: float, + velocity_base: int = 95) -> List[Dict[str, Any]]: + """Generate bass pattern from pattern bank.""" + if variant not in BASS_PATTERN_BANKS: + variant = 'anchor' + + bank = BASS_PATTERN_BANKS[variant] + positions = bank['positions'] + durations = bank['durations'] + style = bank.get('style', 'root') + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 2) + + notes = [] + for bar in range(int(section_length // 4)): + for i, pos in enumerate(positions): + start = pos + (bar * 4.0) + if start < section_length: + duration = durations[i] if i < len(durations) else 0.4 + velocity = max(70, min(120, velocity_base + random.randint(-8, 8))) + + # Adjust pitch based on style + pitch = root_midi + if style == 'ascending' and bar > 0: + pitch += min(bar, 5) # Rise over bars + elif style == 'syncopated' and i % 2 == 1: + pitch += 5 # Fifth on offbeats + + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated bass pattern from bank: variant={variant}, notes={len(notes)}") + return notes + + def _vary_drum_notes(self, notes: List[Dict[str, Any]], role: str, section: Dict[str, Any], + section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + role_variant = str((section.get('drum_role_variants') or {}).get(role, '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + density = float(section.get('density', 1.0)) + _ = int(section.get('energy', 1)) + variant = str(section.get('drum_variant', 'straight')).lower() + swing = float(section.get('swing', 0.0)) + tightness = float(self._current_generation_profile.get('drum_tightness', 1.0)) + rng = self._section_rng(section, role, salt=5) + + if role_variant in DRUM_PATTERN_BANKS.get(role, {}): + logger.debug(f"Using section pattern bank for {role} with variant {role_variant} in section {kind}") + bank_notes = self._generate_drum_pattern_from_bank(role, role_variant, section_length) + if bank_notes: + use_bank_prob = 0.85 if kind in ['intro', 'break', 'outro'] else 0.95 + if rng.random() < use_bank_prob or not notes: + return bank_notes + + if not notes: + if role in DRUM_PATTERN_BANKS: + all_variants = list(DRUM_PATTERN_BANKS[role].keys()) + if all_variants: + fallback_variant = rng.choice(all_variants) + return self._generate_drum_pattern_from_bank(role, fallback_variant, section_length) + return [] + + varied = list(notes) + + if variant == 'skip' and role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride']: + varied = self._apply_density_mask(varied, section, role, keep_probability=min(0.94, max(0.54, density - 0.08))) + elif variant == 'pressure' and role in ['kick', 'hat_closed', 'perc']: + pressure_notes = [] + for bar_start in range(0, int(section_length), 4): + if role == 'kick' and rng.random() > 0.35: + pressure_notes.append(self._make_note(36, min(section_length - 0.05, bar_start + 3.5), 0.12, 92)) + elif role == 'hat_closed' and rng.random() > 0.45: + pressure_notes.append(self._make_note(42, min(section_length - 0.05, bar_start + 3.75), 0.06, 58)) + elif role == 'perc' and rng.random() > 0.5: + pressure_notes.append(self._make_note(50, min(section_length - 0.05, bar_start + 3.25), 0.12, 74)) + varied = self._merge_section_notes(varied, pressure_notes, section_length) + elif variant == 'shuffle' and role not in ['kick', 'clap', 'sc_trigger', 'crash']: + varied = self._apply_swing(varied, swing or (0.035 / max(0.8, tightness)), section_length) + + if swing > 0.0 and role in ['top_loop', 'perc', 'ride']: + varied = self._apply_swing(varied, swing * 0.55, section_length) + + return varied + + def _vary_bass_notes(self, notes: List[Dict[str, Any]], role: str, key: str, + section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + bank_variant = str(section.get('bass_bank_variant', '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + variant = str(section.get('bass_variant', 'anchor')).lower() + + if bank_variant in BASS_PATTERN_BANKS: + logger.debug(f"Using section bass pattern bank for variant {bank_variant} in section {kind}") + return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) + + if not notes: + if bank_variant in BASS_PATTERN_BANKS: + return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) + all_variants = list(BASS_PATTERN_BANKS.keys()) + if all_variants: + rng = self._section_rng(section, role, salt=7) + fallback = rng.choice(all_variants) + return self._generate_bass_pattern_from_bank(fallback, key, section_length) + return [] + + profile_motion = str(self._current_generation_profile.get('bass_motion', 'locked')).lower() + rng = self._section_rng(section, role, salt=7) + root_note = key[:-1] if len(key) > 1 else key + scale_name = 'minor' if 'm' in key.lower() else 'major' + root_midi = self.note_name_to_midi(root_note, 2) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + varied = [] + for index, note in enumerate(notes): + pitch = int(note['pitch']) + start = float(note['start']) + duration = float(note['duration']) + velocity = int(note['velocity']) + + if variant == 'anchor' and (start % 4.0) < 0.001: + pitch = root_midi + duration = max(duration, 0.5) + elif variant == 'bounce' and (start % 1.0) >= 0.5: + velocity = min(124, velocity + 8) + duration = max(0.18, duration * 0.82) + elif variant == 'syncopated' and (start % 1.0) < 0.001 and rng.random() > 0.4: + start = min(section_length - 0.05, start + 0.25) + duration = max(0.16, duration * 0.68) + elif variant == 'pedal' and index % 3 == 0: + pitch = root_midi + + if profile_motion == 'lifted' and index % 8 == 6: + pitch += 12 + elif profile_motion == 'syncopated' and rng.random() > 0.72: + pitch = scale_notes[(index + 4) % len(scale_notes)] + elif profile_motion == 'bouncy' and (start % 4.0) >= 2.0: + velocity = min(124, velocity + 5) + + varied.append(self._make_note(pitch, start, duration, velocity)) + + return self._shape_notes_for_section(varied, kind, role, section_length) + + def _get_section_melodic_variant(self, section: Dict[str, Any]) -> str: + """Get appropriate melodic variant for section with cross-generation diversity.""" + kind = str(section.get('kind', 'drop')).lower() + + if kind not in MELODIC_SECTION_VARIANTS: + return 'motif' + + variants = list(MELODIC_SECTION_VARIANTS[kind]) + valid_variants = [v for v in variants if v in MELODIC_PATTERN_BANKS] + if not valid_variants: + valid_variants = list(MELODIC_PATTERN_BANKS.keys()) + + rng = self._section_rng(section, 'melodic', salt=3) + + if len(valid_variants) > 1: + scored_variants = [] + for v in valid_variants: + penalty = _get_pattern_variant_penalty('melodic', v) + score = rng.random() - penalty + scored_variants.append((score, v)) + scored_variants.sort(reverse=True) + chosen = scored_variants[0][1] + else: + chosen = valid_variants[0] if valid_variants else 'motif' + + _record_pattern_variant_usage('melodic', chosen) + return chosen + + def _generate_melodic_pattern_from_bank(self, variant: str, key: str, + scale_name: str, + section_length: float, + velocity_base: int = 90) -> List[Dict[str, Any]]: + """Generate melodic pattern from pattern bank.""" + if variant not in MELODIC_PATTERN_BANKS: + variant = 'motif' + + bank = MELODIC_PATTERN_BANKS[variant] + intervals = bank['intervals'] + rhythm = bank['rhythm'] + durations = bank['durations'] + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + notes = [] + for bar in range(int(section_length // 4)): + for i, pos in enumerate(rhythm): + start = pos + (bar * 4.0) + if start < section_length: + interval = intervals[i] if i < len(intervals) else intervals[-1] + pitch = scale_notes[interval % len(scale_notes)] + duration = durations[i] if i < len(durations) else 0.3 + velocity = max(60, min(110, velocity_base + random.randint(-10, 10))) + + notes.append(self._make_note(pitch, start, duration, velocity)) + + logger.debug(f"Generated melodic pattern from bank: variant={variant}, notes={len(notes)}") + return notes + + def _vary_melodic_notes(self, notes: List[Dict[str, Any]], role: str, key: str, scale_name: str, + section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: + section = self._ensure_section_pattern_variants(section) + bank_variant = str(section.get('melodic_bank_variant', '') or '').lower() + kind = str(section.get('kind', 'drop')).lower() + + if bank_variant in MELODIC_PATTERN_BANKS: + logger.debug(f"Using section melodic pattern bank for variant {bank_variant} in section {kind}") + return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) + + if not notes: + if bank_variant in MELODIC_PATTERN_BANKS: + return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) + all_variants = list(MELODIC_PATTERN_BANKS.keys()) + if all_variants: + rng = self._section_rng(section, role, salt=11) + fallback = rng.choice(all_variants) + return self._generate_melodic_pattern_from_bank(fallback, key, scale_name, section_length) + return [] + + variant = str(section.get('melodic_variant', 'motif')).lower() + profile_motion = str(self._current_generation_profile.get('melodic_motion', 'restrained')).lower() + rng = self._section_rng(section, role, salt=11) + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + scale_notes = self.get_scale_notes(root_midi, scale_name) + + transformed = [] + for index, note in enumerate(notes): + start = float(note['start']) + pitch = int(note['pitch']) + duration = float(note['duration']) + velocity = int(note['velocity']) + keep = True + + if variant == 'response' and int(start / 2.0) % 2 == 0 and role in ['lead', 'pluck', 'counter']: + keep = False + elif variant == 'lift' and index % 4 == 3: + pitch += 12 + velocity = min(124, velocity + 10) + elif variant == 'descend' and index % 5 == 4: + pitch -= 12 + duration = max(0.16, duration * 0.9) + elif variant == 'drone': + keep = (start % 4.0) < 0.001 or duration >= 0.5 + if keep: + pitch = scale_notes[index % min(3, len(scale_notes))] + duration = max(duration, 1.2) + + if keep and profile_motion in ['anthemic', 'hooky'] and role in ['lead', 'arp', 'pluck']: + if rng.random() > 0.78: + pitch += 12 + elif profile_motion == 'hooky' and rng.random() > 0.84: + start = min(section_length - 0.05, start + 0.25) + + if keep and profile_motion == 'call_response' and role in ['counter', 'pluck'] and (start % 4.0) < 2.0: + velocity = max(52, velocity - 8) + + if keep: + transformed.append(self._make_note(pitch, start, duration, velocity)) + + if role in ['arp', 'pluck'] and float(section.get('swing', 0.0)) > 0.0: + transformed = self._apply_swing(transformed, float(section.get('swing', 0.0)) * 0.45, section_length) + + return self._shape_notes_for_section(transformed, kind, role, section_length) + + def _transpose_notes(self, notes: List[Dict[str, Any]], semitones: int) -> List[Dict[str, Any]]: + return [ + self._make_note(note['pitch'] + semitones, note['start'], note['duration'], note['velocity']) + for note in notes + ] + + def _scale_note_lengths(self, notes: List[Dict[str, Any]], factor: float, minimum: float = 0.1) -> List[Dict[str, Any]]: + scaled = [] + for note in notes: + scaled.append( + self._make_note( + note['pitch'], + note['start'], + max(minimum, float(note['duration']) * factor), + note['velocity'], + ) + ) + return scaled + + def _shape_notes_for_section(self, notes: List[Dict[str, Any]], section_kind: str, role: str, + section_length: float) -> List[Dict[str, Any]]: + if not notes: + return [] + + shaped = [] + for note in notes: + start = float(note['start']) + keep = True + + if section_kind in ['intro', 'outro'] and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter']: + keep = int(start * 2) % 4 == 0 + elif section_kind == 'break' and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter', 'clap', 'hat_open', 'ride']: + keep = int(start) % 4 == 0 + + if keep and start < section_length: + duration = min(float(note['duration']), section_length - start) + shaped.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + return shaped + + def _merge_section_notes(self, base_notes: List[Dict[str, Any]], extra_notes: List[Dict[str, Any]], + section_length: float) -> List[Dict[str, Any]]: + merged = [] + for note in list(base_notes) + list(extra_notes): + start = float(note['start']) + if start >= section_length: + continue + duration = min(float(note['duration']), max(0.05, section_length - start)) + merged.append(self._make_note(note['pitch'], start, duration, note['velocity'])) + merged.sort(key=lambda item: (item['start'], item['pitch'])) + return merged + + def _build_drum_fill(self, role: str, section_length: float, intensity: int) -> List[Dict[str, Any]]: + fill_start = max(0.0, section_length - 1.0) + if role == 'kick' and intensity >= 3: + return [self._make_note(36, fill_start + step, 0.14, 112 + (idx % 2) * 8) for idx, step in enumerate([0.0, 0.25, 0.5, 0.75])] + if role == 'clap' and intensity >= 3: + return [self._make_note(39, fill_start + step, 0.18, 92 + idx * 6) for idx, step in enumerate([0.25, 0.5, 0.75])] + if role == 'hat_closed': + return [self._make_note(42, fill_start + (idx * 0.125), 0.06, 64 + (idx % 4) * 6) for idx in range(8)] + if role == 'perc' and intensity >= 2: + return [ + self._make_note(37, fill_start + 0.125, 0.08, 72), + self._make_note(47, fill_start + 0.375, 0.08, 76), + self._make_note(50, fill_start + 0.625, 0.1, 82), + ] + return [] + + def _build_turnaround_notes(self, key: str, scale_name: str, section_length: float, + octave: int, velocity: int = 92) -> List[Dict[str, Any]]: + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, octave) + scale_notes = self.get_scale_notes(root_midi, scale_name) + fill_start = max(0.0, section_length - 2.0) + degrees = [0, 2, 4, 6] + notes = [] + for index, degree in enumerate(degrees): + pitch = scale_notes[degree % len(scale_notes)] + notes.append(self._make_note(pitch, fill_start + (index * 0.5), 0.38, velocity + index * 4)) + return notes + + def _generate_fill_pattern(self, fill_name: str, start_offset: float) -> Tuple[List[Dict[str, Any]], List[str]]: + """ + Generate fill pattern at specified offset. + + Returns: + (notes, roles) - tuple of note list and list of roles used + """ + if fill_name not in FILL_PATTERNS: + return [], [] + + fill = FILL_PATTERNS[fill_name] + notes = [] + roles_used = [] + + pitch_map = { + 'kick': 36, 'snare': 38, 'hat': 42, 'hat_open': 46, + 'crash': 49, 'ride': 51, 'perc': 50 + } + + for role, positions in fill['pattern'].items(): + roles_used.append(role) + pitch = pitch_map.get(role, 50) + velocity = fill['velocities'].get(role, 90) + + for pos in positions: + start = start_offset + pos + duration = 0.1 if role in ['hat', 'hat_open', 'ride'] else 0.15 + notes.append(self._make_note(pitch, start, duration, velocity)) + + # Track materialization for debugging/logging + if not hasattr(self, '_transition_materialization_log'): + self._transition_materialization_log = [] + self._transition_materialization_log.append({ + 'fill': fill_name, + 'start': start_offset, + 'notes_count': len(notes), + 'roles': roles_used + }) + + return notes, roles_used + + def _generate_transition_events(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Generate fill and transition events between sections.""" + transition_events = [] + + # Calculate start positions for each section + arrangement_time = 0.0 + for section in sections: + section['start'] = arrangement_time + arrangement_time += float(section.get('beats', 0.0) or 0.0) + + for i, section in enumerate(sections): + kind = str(section.get('kind', '')).lower() + start = float(section.get('start', 0.0)) + length = float(section.get('beats', 8.0)) + end = start + length + + # Check for transition to next section + if i < len(sections) - 1: + next_kind = str(sections[i + 1].get('kind', '')).lower() + transition_key = (kind, next_kind) + + if transition_key in TRANSITION_EVENTS: + fills = TRANSITION_EVENTS[transition_key] + rng = self._section_rng(section, 'transition', salt=20) + fill_name = rng.choice(fills) + + # Get notes and roles from fill pattern + fill_notes, fill_roles = self._generate_fill_pattern(fill_name, end - 2.0) + + transition_events.append({ + 'fill': fill_name, + 'start': end - 2.0, + 'section_kind': kind, + 'next_section_kind': next_kind, + 'roles': fill_roles, + 'notes': fill_notes, # Include actual notes for materialization + 'notes_count': len(fill_notes) + }) + logger.debug("TRANSITION: Added '%s' at %.1f for %s->%s", + fill_name, end - 2.0, kind, next_kind) + + return transition_events + + def _apply_transition_density_rules(self, transition_events: List[Dict], + sections: List[Dict]) -> List[Dict]: + """ + Apply anti-overcrowding rules to transition events. + + Returns filtered list of events. + """ + if not transition_events: + return [] + + filtered = [] + last_event_time = {} # Track last time of each event type + section_fill_counts = defaultdict(int) # Track fills per section + + for event in transition_events: + fill_name = event.get('fill', '') + start = event.get('start', 0.0) + section_kind = event.get('section_kind', 'drop') + + # Rule 1: Max fills per section + max_fills = TRANSITION_DENSITY_RULES['max_fills_by_section'].get(section_kind, 2) + if section_fill_counts[section_kind] >= max_fills: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - section '%s' at max (%d fills)", + fill_name, section_kind, max_fills) + continue + + # Rule 2: Minimum distance between same-type events + min_dist = TRANSITION_DENSITY_RULES['min_distance_same_type'].get(fill_name, 0) + if fill_name in last_event_time: + time_since_last = start - last_event_time[fill_name] + if time_since_last < min_dist: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - too close to previous (%.1f < %.1f)", + fill_name, time_since_last, min_dist) + continue + + # Rule 3: Check for exclusive events at same position + skip = False + for existing in filtered: + if abs(existing.get('start', -999) - start) < 0.5: # Same position + for exclusive_set in TRANSITION_DENSITY_RULES['exclusive_events']: + if fill_name in exclusive_set and existing.get('fill') in exclusive_set: + logger.debug("TRANSITION_DENSITY: Skipping '%s' - exclusive with '%s' at %.1f", + fill_name, existing.get('fill'), start) + skip = True + break + if skip: + break + + if skip: + continue + + # Event passes all rules + filtered.append(event) + last_event_time[fill_name] = start + section_fill_counts[section_kind] += 1 + + logger.info("TRANSITION_DENSITY: %d events passed filtering (from %d original)", + len(filtered), len(transition_events)) + + return filtered + + def _transition_events_to_notes(self, transition_events: List[Dict]) -> List[Dict]: + """Convert filtered transition events to MIDI notes.""" + notes = [] + for event in transition_events: + fill_name = event.get('fill', '') + start = event.get('start', 0.0) + fill_notes, _ = self._generate_fill_pattern(fill_name, start) + notes.extend(fill_notes) + return notes + + def _materialize_transition_events(self, config: Dict[str, Any], + track_blueprints: List[Dict]) -> List[Dict]: + """ + Materialize transition events into track blueprints. + + Adds actual MIDI notes to transition-oriented tracks based on transition_events config. + """ + transition_events = config.get('transition_events', []) + if not transition_events: + config['transition_materialization'] = { + 'events_count': 0, + 'materialized': False, + 'note_count': 0, + 'track_roles': [], + } + return track_blueprints + + transition_track_targets = { + 'drum_fill_4bar': 'snare_fill', + 'drum_fill_2bar': 'snare_fill', + 'snare_roll': 'snare_fill', + 'hat_open_build': 'riser', + 'kick_drop': 'impact', + 'crash_impact': 'crash', + } + pitch_to_track_role = { + 36: 'kick', + 38: 'snare_fill', + 42: 'hat_closed', + 46: 'hat_open', + 49: 'crash', + 50: 'perc', + 51: 'ride', + } + + # Build a lookup dict of tracks by role + tracks_by_role = {} + for track in track_blueprints: + role = track.get('role', '') + if role: + tracks_by_role[role] = track + + # Track what was materialized + materialized_count = 0 + materialized_track_roles: set = set() + + # Materialize each transition event + for event in transition_events: + fill_name = event.get('fill', '') + fill_start = event.get('start', 0.0) + fill_notes = event.get('notes', []) + + if not fill_notes: + event['materialized'] = False + event['materialized_notes_count'] = 0 + event['materialized_track_roles'] = [] + continue + + preferred_track_role = transition_track_targets.get(fill_name) + preferred_note_map: Dict[str, List[Dict[str, Any]]] = {} + if preferred_track_role and preferred_track_role in tracks_by_role: + preferred_note_map[preferred_track_role] = list(fill_notes) + + fallback_note_map: Dict[str, List[Dict[str, Any]]] = {} + for note in fill_notes: + note_role = pitch_to_track_role.get(int(note.get('pitch', 0))) + if note_role: + fallback_note_map.setdefault(note_role, []).append(note) + + # Add notes to appropriate tracks + event_materialized_count = 0 + event_track_roles: set = set() + + for notes_by_track_role in [preferred_note_map, fallback_note_map]: + if not notes_by_track_role: + continue + + for track_role, notes_to_add in notes_by_track_role.items(): + if track_role not in tracks_by_role: + logger.debug("TRANSITION_MATERIALIZATION: No track for role '%s', skipping %d notes", + track_role, len(notes_to_add)) + continue + if track_role in event_track_roles: + continue + + track = tracks_by_role[track_role] + clips = track.get('clips', []) + + for clip in clips: + clip_scene_index = clip.get('scene_index', -1) + sections = config.get('sections', []) + if clip_scene_index < 0 or clip_scene_index >= len(sections): + continue + + section = sections[clip_scene_index] + section_start = float(section.get('start', 0.0)) + section_beats = float(section.get('beats', 0.0)) + + if section_start <= fill_start < section_start + section_beats: + existing_notes = clip.get('notes', []) + adjusted_notes = [] + for note in notes_to_add: + adjusted_note = dict(note) + adjusted_note['start'] = note['start'] - section_start + adjusted_notes.append(adjusted_note) + + existing_notes.extend(adjusted_notes) + existing_notes.sort(key=lambda item: (float(item.get('start', 0.0)), int(item.get('pitch', 0)))) + clip['notes'] = existing_notes + materialized_count += len(adjusted_notes) + event_materialized_count += len(adjusted_notes) + materialized_track_roles.add(track_role) + event_track_roles.add(track_role) + + logger.debug("TRANSITION_MATERIALIZATION: Added %d notes to track '%s' (role: %s) for fill '%s' at %.1f", + len(adjusted_notes), track.get('name', ''), track_role, fill_name, fill_start) + break + + if event_materialized_count > 0: + break + + event['materialized'] = event_materialized_count > 0 + event['materialized_notes_count'] = event_materialized_count + event['materialized_track_roles'] = sorted(event_track_roles) + + logger.info("TRANSITION_MATERIALIZATION: Total %d notes materialized across all tracks", materialized_count) + config['transition_materialization'] = { + 'events_count': len(transition_events), + 'materialized': materialized_count > 0, + 'note_count': materialized_count, + 'track_roles': sorted(materialized_track_roles), + } + return track_blueprints + + def _find_reference_track_profile(self) -> Optional[Dict[str, Any]]: + matches: List[Tuple[float, Dict[str, Any]]] = [] + audio_extensions = {'.wav', '.mp3', '.aiff', '.flac', '.aif', '.ogg'} + for directory in REFERENCE_SEARCH_DIRS: + if not directory.exists(): + continue + for candidate in sorted(directory.glob('*')): + if not candidate.is_file(): + continue + if candidate.suffix.lower() not in audio_extensions: + continue + normalized_name = candidate.name.lower() + for profile in REFERENCE_TRACK_PROFILES: + if all(term in normalized_name for term in profile.get('match_terms', [])): + resolved = dict(profile) + resolved['path'] = str(candidate) + resolved['file_name'] = candidate.name + try: + modified = float(candidate.stat().st_mtime) + except Exception: + modified = 0.0 + matches.append((modified, resolved)) + + if not matches: + return None + matches.sort(key=lambda item: item[0], reverse=True) + return matches[0][1] + + def _resolve_reference_track_profile(self, genre: str, style: str, bpm: float, + key: str, structure: str, + reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, Any]]: + profile = self._find_reference_track_profile() + if not profile: + return None + + target_genre = profile.get('genre', '') + compatible_genres = {target_genre, 'techno', 'tech-house', 'house'} + if genre and genre not in compatible_genres: + return None + + if bpm <= 0: + bpm = float(profile.get('bpm', bpm or 0)) + if not key: + key = profile.get('key', key) + if not style: + style = profile.get('style', style) + if not structure or structure == 'standard': + structure = profile.get('structure', structure or 'standard') + + result = { + 'genre': target_genre or genre, + 'style': style, + 'bpm': bpm, + 'key': key, + 'structure': structure, + 'reference': profile, + } + + # Forward energy profile if available + if reference_energy_profile: + result['reference_energy_profile'] = reference_energy_profile + + return result + + def _build_return_states(self, returns: List[Dict[str, Any]], section: Dict[str, Any]) -> List[Dict[str, Any]]: + if not returns: + return [] + + kind = str(section.get('kind', 'drop')).lower() + energy = max(1, int(section.get('energy', 1))) + profile_name = str(self._current_generation_profile.get('name', 'default')).lower() + style_text = str(self._current_generation_profile.get('style_text', '')).lower() + + volume_factors = { + 'space': {'intro': 0.94, 'build': 0.84, 'drop': 0.7, 'break': 1.02, 'outro': 0.86}, + 'echo': {'intro': 0.8, 'build': 1.04, 'drop': 0.72, 'break': 0.92, 'outro': 0.78}, + 'heat': {'intro': 0.56, 'build': 0.88, 'drop': 1.06, 'break': 0.42, 'outro': 0.66}, + 'glue': {'intro': 0.72, 'build': 0.86, 'drop': 1.02, 'break': 0.58, 'outro': 0.74}, + } + space_mix = {'intro': 0.94, 'build': 0.88, 'drop': 0.8, 'break': 1.0, 'outro': 0.9} + echo_mix = {'intro': 0.72, 'build': 0.92, 'drop': 0.62, 'break': 0.84, 'outro': 0.76} + width_targets = {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.16, 'outro': 1.04} + filter_factors = {'intro': 0.86, 'build': 1.0, 'drop': 1.18, 'break': 0.78, 'outro': 0.9} + drive_offsets = {'intro': -1.2, 'build': 0.2, 'drop': 1.0, 'break': -1.6, 'outro': -0.5} + threshold_offsets = {'intro': 1.5, 'build': -0.5, 'drop': -2.0, 'break': 2.5, 'outro': 1.0} + + states = [] + for return_index, return_spec in enumerate(returns): + send_key = str(return_spec.get('send_key', return_spec.get('name', ''))).strip().lower() + if not send_key: + continue + + base_volume = float(return_spec.get('volume', 0.7)) + volume_factor = volume_factors.get(send_key, {}).get(kind, 1.0) + if send_key in ['heat', 'glue'] and energy >= 4: + volume_factor += 0.04 + elif send_key in ['space', 'echo'] and kind == 'break': + volume_factor += 0.04 + + if profile_name == 'warehouse' and send_key == 'heat': + volume_factor += 0.05 + elif profile_name == 'festival' and send_key == 'space': + volume_factor += 0.06 + elif profile_name == 'swing' and send_key == 'echo': + volume_factor += 0.05 + elif profile_name == 'jackin' and send_key == 'glue': + volume_factor += 0.05 + + if 'industrial' in style_text and send_key == 'heat': + volume_factor += 0.05 + if 'latin' in style_text and send_key == 'echo': + volume_factor += 0.06 + + state = { + 'return_index': return_index, + 'send_key': send_key, + 'volume': self._clamp_unit(base_volume * volume_factor), + 'device_parameters': [], + } + + for device_index, device_spec in enumerate(return_spec.get('device_chain', []) or []): + if not isinstance(device_spec, dict): + continue + device_name = str(device_spec.get('device', '') or '').strip() + if not device_name: + continue + device_name_lower = device_name.lower() + base_parameters = dict(device_spec.get('parameters', {})) + parameter_updates = {} + + if send_key == 'space': + if 'hybrid reverb' in device_name_lower: + parameter_updates['Dry/Wet'] = space_mix.get(kind, 0.9) + elif 'auto filter' in device_name_lower: + base_frequency = float(base_parameters.get('Frequency', 8200.0) or 8200.0) + parameter_updates['Frequency'] = round(base_frequency * filter_factors.get(kind, 1.0), 3) + parameter_updates['Dry/Wet'] = {'intro': 0.18, 'build': 0.22, 'drop': 0.08, 'break': 0.28, 'outro': 0.14}.get(kind, 0.16) + elif 'utility' in device_name_lower: + parameter_updates['Stereo Width'] = width_targets.get(kind, 1.08) + elif send_key == 'echo': + if 'echo' in device_name_lower: + parameter_updates['Dry/Wet'] = echo_mix.get(kind, 0.78) + elif 'auto filter' in device_name_lower: + base_frequency = float(base_parameters.get('Frequency', 9800.0) or 9800.0) + parameter_updates['Frequency'] = round(base_frequency * {'intro': 0.94, 'build': 1.08, 'drop': 0.88, 'break': 0.9, 'outro': 0.92}.get(kind, 1.0), 3) + parameter_updates['Dry/Wet'] = {'intro': 0.08, 'build': 0.14, 'drop': 0.06, 'break': 0.16, 'outro': 0.09}.get(kind, 0.1) + elif 'hybrid reverb' in device_name_lower: + parameter_updates['Dry/Wet'] = {'intro': 0.12, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.1}.get(kind, 0.12) + elif send_key == 'heat': + if 'saturator' in device_name_lower: + base_drive = float(base_parameters.get('Drive', 4.5) or 4.5) + parameter_updates['Drive'] = round(max(0.5, base_drive + drive_offsets.get(kind, 0.0)), 3) + elif 'compressor' in device_name_lower: + base_threshold = float(base_parameters.get('Threshold', -16.0) or -16.0) + parameter_updates['Threshold'] = round(base_threshold + threshold_offsets.get(kind, 0.0), 3) + elif send_key == 'glue': + if 'compressor' in device_name_lower: + base_threshold = float(base_parameters.get('Threshold', -18.0) or -18.0) + parameter_updates['Threshold'] = round(base_threshold + {'intro': 1.0, 'build': -0.6, 'drop': -1.4, 'break': 1.8, 'outro': 0.8}.get(kind, 0.0), 3) + elif 'limiter' in device_name_lower: + parameter_updates['Gain'] = {'intro': -0.4, 'build': 0.0, 'drop': 0.35, 'break': -0.6, 'outro': -0.3}.get(kind, 0.0) + + for parameter_name, value in parameter_updates.items(): + state['device_parameters'].append({ + 'device_index': int(device_index), + 'device_name': device_name, + 'parameter': parameter_name, + 'value': value, + }) + + states.append(state) + + return states + +# ========================================================================= + # SECTION AUTOMATION METHODS + # ========================================================================= + + def _generate_automation_envelope( + self, + parameter_start: float, + parameter_end: float, + section_length: float, + curve_name: str = 'linear', + num_points: int = 8 + ) -> List[Dict[str, Any]]: + """ + Generate automation envelope points for a parameter over a section. + + Args: + parameter_start: Starting value of the parameter + parameter_end: Ending value of the parameter + section_length: Length of the section in beats + curve_name: Name of the envelope curve to use + num_points: Number of envelope points to generate + + Returns: + List of automation points with time and value + """ + curve_func = ENVELOPE_CURVES.get(curve_name, ENVELOPE_CURVES['linear']) + envelope_points = [] + + for i in range(num_points): + position = i / (num_points - 1) if num_points > 1 else 0.0 + curved_position = curve_func(position) + value = parameter_start + (parameter_end - parameter_start) * curved_position + time = section_length * position + + envelope_points.append({ + 'time': round(time, 3), + 'value': round(value, 4), + 'curve_position': round(position, 3), + }) + + return envelope_points + + def _build_section_automation( + self, + section: Dict[str, Any], + buses: List[Dict[str, Any]], + returns: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Build automation data for a single section. + + Args: + section: Section configuration dictionary + buses: List of bus track configurations + returns: List of return track configurations + + Returns: + Dictionary containing automation data for the section + """ + kind = str(section.get('kind', 'drop')).lower() + section_length = float(section.get('beats', 32.0)) + energy = float(section.get('energy', 1)) + + # Get base automation template for this section kind + base_automation = SECTION_AUTOMATION.get(kind, SECTION_AUTOMATION.get('drop', {})) + + # Determine envelope curve + curve_name = base_automation.get('envelope_curve', 'linear') + + # Apply energy scaling + energy_factor = max(0.5, min(1.5, energy / 3.0)) + + automation_data = { + 'section_index': int(section.get('index', 0)), + 'section_name': section.get('name', 'SECTION'), + 'section_kind': kind, + 'section_length': section_length, + 'energy': round(base_automation.get('energy', 0.5) * energy_factor, 3), + 'bus_automation': [], + 'return_automation': [], + 'master_automation': {}, + } + + # Build bus automation + for bus in buses: + bus_key = str(bus.get('key', '')).lower() + if not bus_key: + continue + + bus_filter_settings = base_automation.get('filters', {}).get(bus_key, {}) + if not bus_filter_settings: + continue + + bus_auto = { + 'bus_key': bus_key, + 'bus_name': bus.get('name', bus_key.upper()), + 'parameters': [] + } + + # Filter frequency automation + if 'frequency' in bus_filter_settings: + freq_start = bus_filter_settings['frequency'] * (1.1 - energy_factor * 0.2) + freq_end = bus_filter_settings['frequency'] * energy_factor + bus_auto['parameters'].append({ + 'device': 'Auto Filter', + 'parameter': 'Frequency', + 'envelope': self._generate_automation_envelope( + freq_start, freq_end, section_length, curve_name + ), + 'start_value': round(freq_start, 1), + 'end_value': round(freq_end, 1), + }) + + # Filter resonance automation + if 'resonance' in bus_filter_settings: + res_start = bus_filter_settings['resonance'] * 0.8 + res_end = bus_filter_settings['resonance'] * energy_factor + bus_auto['parameters'].append({ + 'device': 'Auto Filter', + 'parameter': 'Resonance', + 'envelope': self._generate_automation_envelope( + res_start, res_end, section_length, 'ease_in_out' + ), + 'start_value': round(res_start, 3), + 'end_value': round(res_end, 3), + }) + + if bus_auto['parameters']: + automation_data['bus_automation'].append(bus_auto) + + # Build return automation + reverb_settings = base_automation.get('reverb', {}) + delay_settings = base_automation.get('delay', {}) + compression_settings = base_automation.get('compression', {}) + saturation_settings = base_automation.get('saturation', {}) + stereo_width_settings = base_automation.get('stereo_width', {}) + + for return_track in returns: + send_key = str(return_track.get('send_key', '')).lower() + if not send_key: + continue + + return_auto = { + 'send_key': send_key, + 'return_name': return_track.get('name', send_key.upper()), + 'parameters': [] + } + + if send_key == 'space' and reverb_settings: + # Reverb send level + return_auto['parameters'].append({ + 'device': 'Hybrid Reverb', + 'parameter': 'Dry/Wet', + 'envelope': self._generate_automation_envelope( + reverb_settings.get('send_level', 0.2) * 0.9, + reverb_settings.get('send_level', 0.2) * energy_factor, + section_length, curve_name + ), + 'start_value': round(reverb_settings.get('send_level', 0.2) * 0.9, 3), + 'end_value': round(reverb_settings.get('send_level', 0.2) * energy_factor, 3), + }) + # Decay time + return_auto['parameters'].append({ + 'device': 'Hybrid Reverb', + 'parameter': 'Decay Time', + 'envelope': self._generate_automation_envelope( + reverb_settings.get('decay_time', 2.0) * 0.85, + reverb_settings.get('decay_time', 2.0), + section_length, 'ease_out' + ), + 'start_value': round(reverb_settings.get('decay_time', 2.0) * 0.85, 2), + 'end_value': round(reverb_settings.get('decay_time', 2.0), 2), + }) + + elif send_key == 'echo' and delay_settings: + # Delay send level + return_auto['parameters'].append({ + 'device': 'Echo', + 'parameter': 'Dry/Wet', + 'envelope': self._generate_automation_envelope( + delay_settings.get('send_level', 0.15) * 0.85, + delay_settings.get('send_level', 0.15) * energy_factor, + section_length, curve_name + ), + 'start_value': round(delay_settings.get('send_level', 0.15) * 0.85, 3), + 'end_value': round(delay_settings.get('send_level', 0.15) * energy_factor, 3), + }) + # Feedback + return_auto['parameters'].append({ + 'device': 'Echo', + 'parameter': 'Feedback', + 'envelope': self._generate_automation_envelope( + delay_settings.get('feedback', 0.3) * 0.8, + delay_settings.get('feedback', 0.3), + section_length, 'ramp_up' + ), + 'start_value': round(delay_settings.get('feedback', 0.3) * 0.8, 3), + 'end_value': round(delay_settings.get('feedback', 0.3), 3), + }) + + elif send_key == 'heat' and saturation_settings: + # Saturation drive + return_auto['parameters'].append({ + 'device': 'Saturator', + 'parameter': 'Drive', + 'envelope': self._generate_automation_envelope( + saturation_settings.get('drive', 2.0) * 0.6, + saturation_settings.get('drive', 2.0) * energy_factor, + section_length, 'ramp_up' + ), + 'start_value': round(saturation_settings.get('drive', 2.0) * 0.6, 2), + 'end_value': round(saturation_settings.get('drive', 2.0) * energy_factor, 2), + }) + + elif send_key == 'glue' and compression_settings: + # Compressor threshold + return_auto['parameters'].append({ + 'device': 'Compressor', + 'parameter': 'Threshold', + 'envelope': self._generate_automation_envelope( + compression_settings.get('threshold', -12.0) + 3, + compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, + section_length, 'ease_in' + ), + 'start_value': round(compression_settings.get('threshold', -12.0) + 3, 1), + 'end_value': round(compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, 1), + }) + + if return_auto['parameters']: + automation_data['return_automation'].append(return_auto) + + # Build master automation + automation_data['master_automation'] = { + 'stereo_width': { + 'parameter': 'Stereo Width', + 'envelope': self._generate_automation_envelope( + stereo_width_settings.get('value', 1.0) * 0.9, + stereo_width_settings.get('value', 1.0), + section_length, 'ease_in_out' + ), + 'start_value': round(stereo_width_settings.get('value', 1.0) * 0.9, 3), + 'end_value': round(stereo_width_settings.get('value', 1.0), 3), + }, + 'compression': { + 'parameter': 'Ratio', + 'envelope': self._generate_automation_envelope( + compression_settings.get('ratio', 2.0) * 0.8, + compression_settings.get('ratio', 2.0) * energy_factor, + section_length, 'ease_in' + ), + 'start_value': round(compression_settings.get('ratio', 2.0) * 0.8, 2), + 'end_value': round(compression_settings.get('ratio', 2.0) * energy_factor, 2), + }, + } + + return automation_data + + def _build_full_automation_blueprint( + self, + sections: List[Dict[str, Any]], + buses: List[Dict[str, Any]], + returns: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Build complete automation blueprint for all sections. + + Args: + sections: List of section configurations + buses: List of bus track configurations + returns: List of return track configurations + + Returns: + List of automation data dictionaries, one per section + """ + automation_blueprint = [] + + for section in sections: + section_automation = self._build_section_automation(section, buses, returns) + automation_blueprint.append(section_automation) + + return automation_blueprint + + def _build_master_state(self, section_kind: str) -> Dict[str, Any]: + """ + Build master chain state for a section. + + Returns a snapshot payload with flat device parameters for master chain. + """ + section = section_kind.lower() + device_parameters = [] + for device_name, parameter_map in MASTER_DEVICE_AUTOMATION.items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop', 0.0)) + clamp = MASTER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_parameters.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + + return { + 'section': section, + 'device_parameters': device_parameters, + } + + def _build_device_parameters_for_role(self, role: str, section_kind: str) -> List[Dict[str, Any]]: + """ + Build flat device parameter automation entries for a track role in a section. + """ + role_lower = role.lower().replace(' ', '_').replace('-', '_') + if role_lower not in SECTION_DEVICE_AUTOMATION: + return [] + section = section_kind.lower() + device_params = [] + for device_name, parameter_map in SECTION_DEVICE_AUTOMATION.get(role_lower, {}).items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop', 0.0)) + clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_params.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + return device_params + + def _build_bus_device_parameters(self, bus_key: str, section_kind: str) -> List[Dict[str, Any]]: + """ + Build flat device parameter automation entries for a bus track in a section. + Uses BUS_DEVICE_AUTOMATION constant for per-section values. + """ + bus_key_lower = bus_key.lower() + if bus_key_lower not in BUS_DEVICE_AUTOMATION: + return [] + section = section_kind.lower() + device_params = [] + for device_name, parameter_map in BUS_DEVICE_AUTOMATION.get(bus_key_lower, {}).items(): + for parameter_name, section_values in parameter_map.items(): + value = section_values.get(section, section_values.get('drop',0.0)) + clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) + if clamp: + value = max(clamp['min'], min(clamp['max'], float(value))) + device_params.append({ + 'device_name': device_name, + 'parameter': parameter_name, + 'value': round(float(value), 3), + }) + return device_params + + def _build_performance_snapshots(self, blueprint_tracks: List[Dict[str, Any]], + sections: List[Dict[str, Any]], + returns: Optional[List[Dict[str, Any]]] = None, + buses: Optional[List[Dict[str, Any]]] = None, + reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: + performance = [] + stereo_roles = {'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pad', 'pluck', 'arp', 'counter', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal'} + profile_pan_width = float(self._current_generation_profile.get('pan_width', 0.12)) + volume_factors = { + 'intro': 0.86, + 'build': 0.94, + 'drop': 1.02, + 'break': 0.78, + 'outro': 0.8, + } + + # Build energy profile lookup by section index for adaptive mixing + energy_by_index = {} + if reference_energy_profile: + for i, ep in enumerate(reference_energy_profile): + energy_by_index[i] = ep.get('energy_mean', 0.5) + else: + # Fallback: use section features if available + for i, section in enumerate(sections): + features = section.get('features', {}) + energy_by_index[i] = features.get('energy_mean', features.get('energy', 0.5)) + + space_send_factors = { + 'intro': 1.15, + 'build': 1.0, + 'drop': 0.82, + 'break': 1.35, + 'outro': 1.05, + } + echo_send_factors = { + 'intro': 1.08, + 'build': 1.18, + 'drop': 0.78, + 'break': 1.45, + 'outro': 0.95, + } + heat_send_factors = { + 'intro': 0.55, + 'build': 0.92, + 'drop': 1.18, + 'break': 0.42, + 'outro': 0.72, + } + glue_send_factors = { + 'intro': 0.72, + 'build': 0.96, + 'drop': 1.08, + 'break': 0.58, + 'outro': 0.78, + } + + for section_idx, section in enumerate(sections): + kind = str(section.get('kind', 'drop')).lower() + energy = max(1, int(section.get('energy', 1))) + + # Get energy_mean from reference profile for adaptive volume scaling + ref_energy_mean = energy_by_index.get(section_idx, 0.5) + + snapshot = { + 'scene_index': int(section.get('index', len(performance))), + 'name': section.get('name', "SECTION"), + 'track_states': [], + 'return_states': self._build_return_states(list(returns or []), section), + 'bus_states': [], + } + + for track_index, track_data in enumerate(blueprint_tracks): + role = track_data.get('role', '') + base_volume = float(track_data.get('volume', 0.72)) + base_pan = float(track_data.get('pan', 0.0)) + base_sends = dict(track_data.get('sends', {})) + intensity = self._role_intensity(role, section) + is_muted = role != 'sc_trigger' and intensity <= 0 + + if is_muted: + target_volume = round(base_volume * 0.08, 3) + else: + factor = volume_factors.get(kind, 1.0) + max(0.0, (energy - 3) * 0.03) + if role in ['kick', 'sub_bass', 'bass'] and kind == 'drop': + factor += 0.04 + if role in ['pad', 'atmos', 'drone'] and kind == 'break': + factor += 0.08 + if role in ['reverse_fx', 'riser', 'impact'] and kind in ['build', 'break']: + factor += 0.06 * float(self._current_generation_profile.get('fx_bias', 1.0)) + + # Apply energy-based volume scaling from reference profile + if ref_energy_mean < 0.3: + # Quiet sections (intro, quiet breaks) - reduce volume + energy_volume_factor = 0.85 + elif ref_energy_mean > 0.7: + # High energy sections (drops, peaks) - boost volume + energy_volume_factor = 1.08 + else: + energy_volume_factor = 1.0 + + target_volume = round(min(1.0, max(0.0, base_volume * factor * energy_volume_factor)), 3) + + target_pan = base_pan + pan_variant = str(section.get('pan_variant', 'narrow')).lower() + if role in stereo_roles: + if pan_variant == 'tilt_left': + direction = -1.0 + width = profile_pan_width + elif pan_variant == 'tilt_right': + direction = 1.0 + width = profile_pan_width + elif pan_variant == 'wide': + direction = -1.0 if track_index % 2 == 0 else 1.0 + width = profile_pan_width * 1.1 + else: + direction = -1.0 if track_index % 2 == 0 else 1.0 + width = profile_pan_width * 0.55 + + if kind == 'break': + width *= 1.18 + elif kind == 'drop': + width *= 0.92 + target_pan = self._clamp_pan(base_pan + (direction * width)) + + target_sends = {} + for send_name, send_value in base_sends.items(): + send_factor = 1.0 + if send_name == 'space': + send_factor = space_send_factors.get(kind, 1.0) + elif send_name == 'echo': + send_factor = echo_send_factors.get(kind, 1.0) + elif send_name == 'heat': + send_factor = heat_send_factors.get(kind, 1.0) + elif send_name == 'glue': + send_factor = glue_send_factors.get(kind, 1.0) + + if role in ['riser', 'impact'] and kind in ['build', 'break']: + send_factor += 0.18 + if role == 'vocal' and kind in ['build', 'drop']: + send_factor += 0.12 + if role in ['kick', 'sub_bass', 'bass'] and send_name in ['heat', 'glue'] and kind == 'drop': + send_factor += 0.1 + if is_muted: + send_factor *= 0.25 + + target_sends[send_name] = round(min(1.0, max(0.0, float(send_value) * send_factor)), 3) + + track_state = { + 'track_index': track_index, + 'role': role, + 'mute': is_muted, + 'volume': target_volume, + 'pan': target_pan, + 'sends': target_sends, + } + + # Add device_parameters to track state + device_params = self._build_device_parameters_for_role(role, kind) + if device_params: + track_state['device_parameters'] = device_params + + snapshot['track_states'].append(track_state) + + # Add bus states to snapshot + for bus_data in list(buses or []): + bus_key = str(bus_data.get('key', '')).lower() + if not bus_key: + continue + bus_device_params = self._build_bus_device_parameters(bus_key, kind) + if bus_device_params: + bus_state = { + 'bus_key': bus_key, + 'bus_name': bus_data.get('name', bus_key.upper()), + 'device_parameters': bus_device_params, + } + snapshot['bus_states'].append(bus_state) + + # Add master state to snapshot + master_state = self._build_master_state(kind) + if master_state.get('device_parameters'): + snapshot['master_state'] = master_state + + performance.append(snapshot) + + return performance + + def _build_mix_automation_summary(self, performance: List[Dict]) -> Dict[str, Any]: + """ + Build summary of automation in performance snapshots. + + Returns: + - track_snapshots_with_device_automation: count + - return_snapshots_with_device_automation: count + - bus_snapshots_with_device_automation: count + - master_snapshots_count: count + - track_roles_touched: list of roles with device automation + - bus_keys_touched: list of bus keys with device automation + - master_parameters_touched: list of master params automated + """ + track_count = 0 + return_count = 0 + bus_count = 0 + master_count = 0 + track_roles = set() + bus_keys = set() + master_params = set() + + for snapshot in performance: + # Check track states + for track_state in snapshot.get('track_states', []): + if 'device_parameters' in track_state and track_state['device_parameters']: + track_count += 1 + role = track_state.get('role', 'unknown') + track_roles.add(role) + + # Check return states + for return_state in snapshot.get('return_states', []): + if 'device_parameters' in return_state and return_state['device_parameters']: + return_count += 1 + + # Check bus states + for bus_state in snapshot.get('bus_states', []): + if 'device_parameters' in bus_state and bus_state['device_parameters']: + bus_count += 1 + bus_key = bus_state.get('bus_key', 'unknown') + bus_keys.add(bus_key) + + # Check master state + master_state = snapshot.get('master_state', {}) + if master_state.get('device_parameters'): + master_count += 1 + for item in master_state.get('device_parameters', []): + param_name = str(item.get('parameter', '') or '').strip() + if param_name: + master_params.add(param_name) + + return { + 'track_snapshots_with_device_automation': track_count, + 'return_snapshots_with_device_automation': return_count, + 'bus_snapshots_with_device_automation': bus_count, + 'master_snapshots_count': master_count, + 'track_roles_touched': sorted(list(track_roles)), + 'bus_keys_touched': sorted(list(bus_keys)), + 'master_parameters_touched': sorted(list(master_params)) + } + + def _verify_automation_safety(self, performance: List[Dict]) -> List[str]: + """ + Verify automation values are within safe ranges. + + Returns list of warnings if any values are outside safe ranges. + """ + warnings = [] + + for i, snapshot in enumerate(performance): + # Check master state + master_state = snapshot.get('master_state', {}) + for item in master_state.get('device_parameters', []): + device_name = str(item.get('device_name', 'unknown')) + param_name = str(item.get('parameter', '') or '').strip() + value = float(item.get('value', 0.0)) + clamp = MASTER_SAFETY_CLAMPS.get(param_name) + if clamp and (value < clamp['min'] or value > clamp['max']): + warnings.append(f"Snapshot {i}: {device_name}.{param_name}={value} outside safe range [{clamp['min']}, {clamp['max']}]") + + return warnings + + def _build_gain_staging_summary(self, config: Dict[str, Any]) -> Dict[str, Any]: + """ + Build gain staging summary for the generated config. + """ + warnings = [] + + # Check bus volumes for extreme values + bus_volumes = self._calibrated_bus_volumes or {} + for bus_name, vol in bus_volumes.items(): + if vol > 0.9: + warnings.append(f"Bus {bus_name} volume > 0.9: {vol:.3f}") + + # Check master limiter gain + master = config.get('master', {}) + master_limiter_gain = 0.0 + for device in master.get('device_chain', []): + if device.get('device') == 'Limiter': + master_limiter_gain = device.get('parameters', {}).get('Gain', 0.0) + if master_limiter_gain > 1.0: + warnings.append(f"Master limiter gain > 1.0: {master_limiter_gain:.3f}") + + # Check track volumes + for track in config.get('tracks', []): + vol = track.get('volume', 0.0) + role = track.get('role', 'unknown') + if vol > 0.9: + warnings.append(f"Track {role} volume > 0.9: {vol:.3f}") + + return { + 'master_profile_used': getattr(self, '_master_profile_used', 'default'), + 'style_adjustments_applied': getattr(self, '_style_adjustments_applied', []), + 'bus_volumes': bus_volumes, + 'track_volume_overrides_count': getattr(self, '_gain_calibration_overrides_count', 0), + 'peak_reductions_applied_count': getattr(self, '_peak_reductions_count', 0), + 'headroom_target_db': TARGET_HEADROOM_DB, + 'warnings': warnings, + } + + def generate_config(self, genre: str, style: str = "", bpm: float = 0, + key: str = "", structure: str = "standard") -> Dict[str, Any]: + """ + Genera una configuración completa de track + + Args: + genre: Género musical + style: Sub-estilo + bpm: BPM (0 = auto) + key: Tonalidad ("" = auto) + structure: Tipo de estructura + """ + genre = genre.lower().replace(' ', '-') + style = style.lower() if style else "" + variant_seed = random.SystemRandom().randint(1000, 999999) + random.seed(variant_seed) + + # Decay pattern variant memory to allow reuse + _decay_pattern_variant_memory() + + # Reset gain staging counters + self._gain_calibration_overrides_count = 0 + self._peak_reductions_count = 0 + self._style_adjustments_applied = [] + self._calibrated_bus_volumes = {} + self._master_profile_used = 'default' + + reference_resolution = self._resolve_reference_track_profile(genre, style, bpm, key, structure) + if reference_resolution: + genre = reference_resolution.get('genre', genre) or genre + style = reference_resolution.get('style', style) + bpm = float(reference_resolution.get('bpm', bpm or 0)) + key = reference_resolution.get('key', key) + structure = reference_resolution.get('structure', structure) + + # Obtener configuración del género + genre_config = GENRE_CONFIGS.get(genre, GENRE_CONFIGS['techno']) + + # Determinar BPM + if bpm <= 0: + bpm = genre_config['default_bpm'] + + # Determinar key + if not key: + key = random.choice(genre_config['keys']) + + # Determinar estilo si no se especificó + if not style: + style = random.choice(genre_config['styles']) + + # Parsear key + _root_note = key[:-1] if len(key) > 1 else key # noqa: F841 - parsed when needed per section + is_minor = 'm' in key.lower() + scale = 'minor' if is_minor else 'major' + profile = self._build_arrangement_profile(genre, style, variant_seed) + profile['style_text'] = f"{genre} {style}".strip().lower() + profile['reference_name'] = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() + self._current_generation_profile = profile + sections = self._build_sections(structure, style, variant_seed, profile) + + # Crear configuración base + config = { + 'name': f"{genre.title()} {style.title()}", + 'bpm': bpm, + 'key': key, + 'scale': scale, + 'genre': genre, + 'style': style, + 'structure': structure, + 'variant_seed': variant_seed, + 'arrangement_profile': profile['name'], + 'reference_track': reference_resolution.get('reference') if reference_resolution else None, + 'reference_energy_profile': reference_resolution.get('reference_energy_profile') if reference_resolution else None, + 'auto_generate': True, + 'sections': sections, + 'buses': self._build_mix_bus_blueprint(profile, genre, style, reference_resolution), + 'returns': self._build_return_blueprint(profile, genre, style, reference_resolution), + 'master': self._build_master_blueprint(profile, genre, style, reference_resolution), + 'tracks': [], + } + + # Generar tracks según género + config['tracks'] = self._generate_tracks_for_genre(genre, style, key, scale, structure, sections, profile) + config['performance'] = self._build_performance_snapshots(config['tracks'], sections, config.get('returns', []), config.get('buses', [])) + config['mix_automation_summary'] = self._build_mix_automation_summary(config['performance']) + config['mix_automation_warnings'] = self._verify_automation_safety(config['performance']) + config['gain_staging_summary'] = self._build_gain_staging_summary(config) + config['automation'] = self._build_full_automation_blueprint(sections, config.get('buses', []), config.get('returns', [])) + config['transition_events'] = self._generate_transition_events(sections) + + # Apply density rules to prevent overcrowding + config['transition_events'] = self._apply_transition_density_rules(config['transition_events'], sections) + + # Materialize transition events into track blueprints + config['tracks'] = self._materialize_transition_events(config, config['tracks']) + + config['locators'] = self._build_locators(sections) + config['total_bars'] = sum(section['bars'] for section in sections) + config['total_beats'] = float(config['total_bars'] * 4) + + # Add section variants summary + config['section_variants'] = { + section.get('name', f'section_{i}'): { + 'kind': section.get('kind', 'unknown'), + 'drum_variant': section.get('drum_variant', 'straight'), + 'kick_variant': section.get('kick_variant', (section.get('drum_role_variants') or {}).get('kick', 'straight')), + 'clap_variant': section.get('clap_variant', (section.get('drum_role_variants') or {}).get('clap', 'straight')), + 'hat_closed_variant': section.get('hat_closed_variant', (section.get('drum_role_variants') or {}).get('hat_closed', 'straight')), + 'bass_variant': section.get('bass_variant', 'anchor'), + 'bass_bank_variant': section.get('bass_bank_variant', section.get('bass_variant', 'anchor')), + 'melodic_variant': section.get('melodic_variant', 'motif'), + 'melodic_bank_variant': section.get('melodic_bank_variant', section.get('melodic_variant', 'motif')), + 'transition_fill': section.get('transition_fill', 'none'), + } + for i, section in enumerate(sections) + } + + # Crear summary + config['summary'] = f""" +🎵 Track Generado: {config['name']} +♩ BPM: {bpm} +🎹 Key: {key} +🎨 Style: {style} +📊 Tracks: {len(config['tracks'])} +""" + if config.get('reference_track'): + config['summary'] += f"🔊 Reference: {config['reference_track'].get('name')}\n" + + return config + + def _build_locators(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + locators = [] + arrangement_time = 0.0 + for section in sections: + locators.append({ + 'scene_index': int(section.get('index', len(locators))), + 'name': section.get('name', 'SECTION'), + 'bars': int(section.get('bars', 8)), + 'color': int(section.get('color', 10)), + 'time_beats': round(arrangement_time, 3), + }) + arrangement_time += float(section.get('beats', 0.0) or 0.0) + return locators + + def _generate_tracks_for_genre(self, genre: str, style: str, key: str, + scale: str, structure: str, sections: List[Dict[str, Any]], + profile: Optional[Dict[str, Any]] = None) -> List[Dict]: + """Genera la configuración de tracks según el género""" + track_specs = [] + style_text = f"{genre} {style}".lower() + + track_specs.extend([ + ('SC TRIGGER', 'sc_trigger', TRACK_COLORS['technical'], 'operator'), + ('KICK', 'kick', TRACK_COLORS['kick'], 'operator'), + ('CLAP', 'clap', TRACK_COLORS['clap'], 'operator'), + ('SNARE FILL', 'snare_fill', TRACK_COLORS['snare'], 'operator'), + ('HAT CLOSED', 'hat_closed', TRACK_COLORS['hat'], 'operator'), + ('HAT OPEN', 'hat_open', TRACK_COLORS['hat'], 'operator'), + ('TOP LOOP', 'top_loop', TRACK_COLORS['hat'], 'operator'), + ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), + ('TOM FILL', 'tom_fill', TRACK_COLORS['perc'], 'operator'), + ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), + ('BASS', 'bass', TRACK_COLORS['bass'], 'operator'), + ('DRONE', 'drone', TRACK_COLORS['pad'], 'analog'), + ('CHORDS', 'chords', TRACK_COLORS['chords'], 'wavetable'), + ('STAB', 'stab', TRACK_COLORS['synth'], 'operator'), + ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), + ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), + ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), + ('COUNTER', 'counter', TRACK_COLORS['synth'], 'operator'), + ('CRASH', 'crash', TRACK_COLORS['fx'], 'operator'), + ('REVERSE FX', 'reverse_fx', TRACK_COLORS['fx'], 'analog'), + ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), + ('IMPACT FX', 'impact', TRACK_COLORS['fx'], 'operator'), + ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), + ]) + tracks = [] + + # Synths/Chords según género + if genre in ['house', 'trance', 'progressive']: + tracks.append(self._generate_chord_track(key, scale, genre)) + tracks.append(self._generate_lead_track(key, scale, genre)) + elif genre in ['techno', 'tech-house']: + if random.random() > 0.3: # 70% de probabilidad + tracks.append(self._generate_chord_track(key, scale, genre)) + if random.random() > 0.5: + tracks.append(self._generate_lead_track(key, scale, genre)) + + # FX/Atmósfera para estructuras extended + if structure in ['extended', 'club'] or random.random() > 0.6: + tracks.append(self._generate_fx_track()) + + if genre in ['techno', 'tech-house', 'trance']: + track_specs.insert(8, ('RIDE', 'ride', TRACK_COLORS['ride'], 'operator')) + if genre in ['house', 'tech-house', 'trance'] or 'latin' in style_text: + track_specs.insert(14, ('PLUCK', 'pluck', TRACK_COLORS['synth'], 'wavetable')) + track_specs.insert(15, ('VOCAL CHOP', 'vocal', TRACK_COLORS['vocal'], 'wavetable')) + elif genre == 'drum-and-bass': + track_specs = [ + ('BREAK', 'kick', TRACK_COLORS['kick'], 'operator'), + ('SNARE', 'clap', TRACK_COLORS['snare'], 'operator'), + ('HATS', 'hat_closed', TRACK_COLORS['hat'], 'operator'), + ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), + ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), + ('REESE', 'bass', TRACK_COLORS['bass'], 'operator'), + ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), + ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), + ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), + ('VOCAL', 'vocal', TRACK_COLORS['vocal'], 'wavetable'), + ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), + ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), + ] + + blueprint_tracks = [] + active_profile = dict(profile or self._current_generation_profile or {'name': 'default'}) + # NTH-04: Resolve genre-specific colors when available + genre_palette = GENRE_COLOR_PALETTES.get(genre, {}) + for name, role, default_color, device in track_specs: + clips = self._build_scene_clips(role, genre, style, key, scale, sections) + if not clips: + continue + + mix_profile = dict(ROLE_MIX.get(role, {})) + mix_profile['sends'] = self._extend_parallel_sends(role, mix_profile.get('sends', {})) + mix_profile = self._shape_mix_profile(role, mix_profile, active_profile, style) + # NTH-04: Use genre-specific color if available, otherwise default + resolved_color = genre_palette.get(role, default_color) + track = { + 'name': name, + 'type': 'midi', + 'role': role, + 'bus': self._resolve_bus_for_role(role), + 'device': device, + 'color': resolved_color, + 'volume': mix_profile.get('volume', 0.72), + 'pan': mix_profile.get('pan', 0.0), + 'sends': dict(mix_profile.get('sends', {})), + 'fx_chain': self._shape_role_fx_chain(role, active_profile, style), + 'clips': clips, + } + track['clip'] = dict(clips[0]) + + # Agregar metadata de variación al blueprint + if role in SECTION_VARIATION_CONFIG: + track['section_variation'] = SECTION_VARIATION_CONFIG[role] + track['can_vary_by_section'] = True + + blueprint_tracks.append(track) + + return blueprint_tracks + + def _build_sections(self, structure: str, style: str = "", variant_seed: Optional[int] = None, + profile: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + structure_key = structure.lower() + rng = random.Random(variant_seed) if variant_seed is not None else random + blueprint_options = SECTION_BLUEPRINT_VARIANTS.get(structure_key) + if blueprint_options: + if 'latin' in style and structure_key == 'club' and len(blueprint_options) > 1: + blueprint = rng.choice(blueprint_options[1:]) + else: + blueprint = rng.choice(blueprint_options) + else: + blueprint = SECTION_BLUEPRINTS.get(structure_key, SECTION_BLUEPRINTS['standard']) + sections = [] + style_text = style.lower() if style else "" + profile_name = str((profile or {}).get('name', 'default')).lower() + for index, (name, bars, color, kind, energy) in enumerate(blueprint): + if kind == 'intro': + drum_variants = ['straight', 'skip'] + bass_variants = ['anchor', 'pedal'] + melodic_variants = ['motif', 'response'] + elif kind == 'build': + drum_variants = ['shuffle', 'pressure', 'straight'] + bass_variants = ['bounce', 'syncopated'] + melodic_variants = ['lift', 'response'] + elif kind == 'break': + drum_variants = ['skip', 'shuffle'] + bass_variants = ['pedal', 'anchor'] + melodic_variants = ['drone', 'response'] + elif kind == 'outro': + drum_variants = ['straight', 'skip'] + bass_variants = ['anchor', 'pedal'] + melodic_variants = ['motif', 'descend'] + else: + drum_variants = ['straight', 'pressure', 'shuffle'] + bass_variants = ['syncopated', 'bounce', 'anchor'] + melodic_variants = ['lift', 'motif', 'descend'] + + swing_pool = [0.0, 0.015, 0.025] + if 'latin' in style_text or profile_name in ['jackin', 'swing']: + swing_pool.extend([0.035, 0.045, 0.055]) + + pan_variant = rng.choice(['narrow', 'wide', 'tilt_left', 'tilt_right']) + if kind in ['intro', 'outro'] and rng.random() > 0.5: + pan_variant = 'narrow' + if kind == 'break' and rng.random() > 0.4: + pan_variant = 'wide' + + section_data = { + 'index': index, + 'name': name, + 'bars': int(bars), + 'beats': float(bars * 4), + 'color': color, + 'kind': kind, + 'energy': int(energy), + 'density': round(min(1.35, max(0.68, 0.78 + (energy * 0.08) + rng.uniform(-0.08, 0.14))), 3), + 'swing': round(rng.choice(swing_pool), 3), + 'tension': int(min(5, max(1, energy + rng.choice([-1, 0, 0, 1])))), + 'drum_variant': rng.choice(drum_variants), + 'bass_variant': rng.choice(bass_variants), + 'melodic_variant': rng.choice(melodic_variants), + 'pan_variant': pan_variant, + 'transition_fill': rng.choice(['none', 'snare', 'tom', 'reverse', 'impact']), + } + sections.append(self._ensure_section_pattern_variants(section_data)) + # Check for excessive repetition and force variation if needed + sections = self._check_section_repetition(sections) + return sections + + def _role_intensity(self, role: str, section: Dict[str, Any]) -> int: + kind = section.get('kind', 'drop') + energy = int(section.get('energy', 1)) + role_energy = ROLE_ACTIVITY.get(role, {}).get(kind, 0) + return min(max(role_energy, 0), max(1, energy + 1)) + + def _build_scene_clips(self, role: str, genre: str, style: str, key: str, + scale: str, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + clips = [] + for section in sections: + notes = self._render_scene_notes(role, genre, style, key, scale, section) + if not notes: + continue + + clips.append({ + 'scene_index': section['index'], + 'length': section['beats'], + 'name': f"{role.upper()} - {section['name']}", + 'notes': notes, + }) + return clips + + def _render_scene_notes(self, role: str, genre: str, style: str, key: str, + scale: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: + intensity = self._role_intensity(role, section) + if intensity <= 0: + return [] + + if role in ['sc_trigger', 'kick', 'clap', 'snare_fill', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'tom_fill', 'ride', 'crash']: + return self._render_drum_scene(role, genre, style, section, intensity) + if role in ['sub_bass', 'bass']: + return self._render_bass_scene(role, genre, style, key, section) + if role in ['chords', 'stab', 'pad', 'pluck', 'arp', 'lead', 'counter']: + return self._render_musical_scene(role, genre, key, scale, section) + if role in ['drone', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal']: + return self._render_fx_scene(role, key, section) + return [] + + def _render_drum_scene(self, role: str, genre: str, style: str, + section: Dict[str, Any], intensity: int) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + style_text = f"{genre} {style}".lower() + + if role == 'sc_trigger': + pattern = [self._make_note(24, beat, 0.12, 127) for beat in [0.0, 1.0, 2.0, 3.0]] + if kind == 'break': + pattern = [self._make_note(24, beat, 0.1, 118) for beat in [0.0, 2.0]] + return self._repeat_pattern(pattern, total_length, 4.0) + + if role == 'kick': + if genre == 'drum-and-bass': + pattern = [ + self._make_note(36, 0.0, 0.25, 122), + self._make_note(36, 0.75, 0.2, 104), + self._make_note(36, 1.5, 0.2, 112), + self._make_note(36, 2.0, 0.25, 124), + self._make_note(36, 2.75, 0.2, 100), + self._make_note(36, 3.25, 0.2, 92), + ] + elif kind == 'break': + pattern = [ + self._make_note(36, 0.0, 0.25, 118), + self._make_note(36, 2.0, 0.25, 110), + ] + else: + pattern = [self._make_note(36, beat, 0.25, 126 if beat == 0 else 118) for beat in [0.0, 1.0, 2.0, 3.0]] + if intensity >= 4 and genre in ['techno', 'tech-house']: + pattern.append(self._make_note(36, 3.5, 0.15, 94)) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop', 'outro']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'clap': + pitch = 38 if genre == 'drum-and-bass' else 39 + if kind == 'intro': + pattern = [self._make_note(pitch, 3.0, 0.2, 88)] + elif kind == 'break': + pattern = [self._make_note(pitch, 1.0, 0.2, 84)] + else: + pattern = [ + self._make_note(pitch, 1.0, 0.25, 108), + self._make_note(pitch, 3.0, 0.25, 108), + ] + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'snare_fill': + if kind not in ['build', 'break', 'drop']: + return [] + if str(section.get('transition_fill', 'snare')).lower() not in ['snare', 'impact'] and kind != 'drop': + return [] + fill_span = 2.0 if kind == 'build' and total_length >= 8.0 else 1.0 + fill_start = max(0.0, total_length - fill_span) + step = 0.25 if intensity <= 2 else 0.125 + velocity = 76 + notes = [] + current = fill_start + while current < total_length - 0.01: + notes.append(self._make_note(38, current, 0.08 if step < 0.2 else 0.12, min(124, velocity))) + current += step + velocity += 3 + if kind == 'drop': + notes.insert(0, self._make_note(38, 0.0, 0.15, 102)) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'hat_closed': + if intensity <= 1: + pattern = [self._make_note(42, beat, 0.1, 86) for beat in [0.5, 1.5, 2.5, 3.5]] + elif intensity == 2: + pattern = [self._make_note(42, step * 0.5, 0.1, 90 if step % 2 == 0 else 72) for step in range(8)] + else: + pattern = [self._make_note(42, step * 0.5, 0.1, 92 if step % 2 == 0 else 74) for step in range(8)] + pattern.extend([self._make_note(42, 1.75, 0.08, 64), self._make_note(42, 3.75, 0.08, 62)]) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop', 'outro']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'hat_open': + if kind in ['intro', 'break'] and intensity <= 1: + return [] + pattern = [self._make_note(46, 3.5, 0.35, 82)] + if intensity >= 3: + pattern.append(self._make_note(46, 1.5, 0.25, 74)) + notes = self._repeat_pattern(pattern, total_length, 4.0) + if kind in ['build', 'drop']: + notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'top_loop': + if kind in ['intro', 'break'] and intensity <= 1: + return [] + pattern = [ + self._make_note(44, 0.25, 0.08, 56), + self._make_note(44, 0.75, 0.08, 62), + self._make_note(44, 1.25, 0.08, 58), + self._make_note(44, 1.75, 0.08, 66), + self._make_note(44, 2.25, 0.08, 58), + self._make_note(44, 2.75, 0.08, 64), + self._make_note(44, 3.25, 0.08, 60), + self._make_note(44, 3.75, 0.08, 68), + ] + if 'latin' in style_text: + pattern.extend([ + self._make_note(54, 0.5, 0.08, 52), + self._make_note(54, 2.5, 0.08, 54), + ]) + if intensity >= 3: + pattern.extend([ + self._make_note(44, 1.125, 0.06, 48), + self._make_note(44, 3.125, 0.06, 50), + ]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'perc': + if kind in ['intro', 'outro'] and intensity <= 1: + return [] + pattern = [ + self._make_note(37, 0.75, 0.1, 62), + self._make_note(37, 1.25, 0.1, 58), + self._make_note(37, 2.75, 0.1, 64), + self._make_note(50, 3.25, 0.12, 70), + ] + if 'latin' in style_text: + pattern.extend([ + self._make_note(64, 1.75, 0.12, 68), + self._make_note(64, 2.125, 0.12, 64), + ]) + if intensity >= 3: + pattern.extend([self._make_note(37, 0.25, 0.1, 56), self._make_note(47, 2.25, 0.1, 68)]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'tom_fill': + if kind not in ['build', 'drop']: + return [] + if str(section.get('transition_fill', 'tom')).lower() not in ['tom', 'impact'] and kind != 'drop': + return [] + fill_start = max(0.0, total_length - 1.0) + sequence = [47, 50, 45, 47, 50] + velocities = [72, 76, 80, 88, 96] + notes = [] + for index, pitch in enumerate(sequence): + start = fill_start + (index * 0.2) + if start >= total_length: + break + notes.append(self._make_note(pitch, start, 0.18, velocities[index])) + return self._vary_drum_notes(notes, role, section, total_length) + + if role == 'ride': + if kind not in ['build', 'drop', 'outro']: + return [] + pattern = [self._make_note(51, float(beat), 0.2, 82) for beat in range(4)] + if intensity >= 3: + pattern.extend([self._make_note(51, beat + 0.5, 0.15, 64) for beat in range(4)]) + return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) + + if role == 'crash': + if kind not in ['build', 'drop', 'break', 'outro']: + return [] + hit_positions = [0.0] + if kind == 'drop' and total_length >= 16.0: + hit_positions.append(8.0) + if kind == 'outro' and total_length >= 8.0: + hit_positions.append(total_length - 4.0) + notes = [ + self._make_note(49, position, min(1.5, max(0.25, total_length - position)), 82 if position == 0.0 else 70) + for position in hit_positions + if position < total_length + ] + return self._vary_drum_notes(notes, role, section, total_length) + + return [] + + def _bass_style_for_section(self, genre: str, style: str, role: str, section_kind: str) -> str: + style_text = f"{genre} {style}".lower() + if role == 'sub_bass': + return 'minimal' if section_kind != 'drop' else 'offbeat' + if 'acid' in style_text: + return 'acid' + if genre == 'house': + return 'offbeat' + if genre == 'drum-and-bass': + return 'rolling' + if section_kind in ['intro', 'outro', 'break']: + return 'minimal' + if genre == 'tech-house': + return 'offbeat' + return 'rolling' + + def _render_bass_scene(self, role: str, genre: str, style: str, key: str, + section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + scale_name = 'minor' if 'm' in key.lower() else 'major' + + if kind == 'break': + notes = self._build_pad_motion(key, scale_name, total_length, 2, 4.0) + else: + notes = self.create_bassline(key, self._bass_style_for_section(genre, style, role, kind), total_length) + + if role == 'sub_bass': + notes = self._transpose_notes(notes, -12) + notes = self._scale_note_lengths(notes, 1.35, minimum=0.2) + notes = self._vary_bass_notes(notes, role, key, section, total_length) + if kind in ['build', 'drop'] and total_length >= 8.0: + turnaround = self._build_turnaround_notes(key, scale_name, total_length, 2 if role == 'bass' else 1, 88 if role == 'bass' else 80) + notes = self._merge_section_notes(notes, turnaround, total_length) + return notes + + def _render_musical_scene(self, role: str, genre: str, key: str, scale: str, + section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section['kind'] + + if role == 'pad': + notes = self._build_pad_motion(key, scale, total_length, 4, 8.0 if kind == 'break' else 4.0) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + if role == 'chords': + progression_type = 'techno' if genre in ['techno', 'tech-house'] else ('trance' if genre == 'trance' else 'house') + notes = self.create_chord_progression(key, progression_type, total_length) + notes = self._scale_note_lengths(notes, 1.15, minimum=0.25) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + if role == 'stab': + notes = self.create_chord_progression(key, 'techno' if genre in ['techno', 'tech-house'] else 'house', total_length) + notes = self._scale_note_lengths(notes, 0.4, minimum=0.1) + shifted = [] + for note in notes: + start = float(note['start']) + (0.5 if int(float(note['start'])) % 2 == 0 else 0.0) + shifted.append(self._make_note(note['pitch'], start, note['duration'], min(118, note['velocity'] + 6))) + return self._vary_melodic_notes(shifted, role, key, scale, section, total_length) + + if role == 'pluck': + notes = self.create_melody(key, scale, total_length, genre) + notes = self._scale_note_lengths(notes, 0.55, minimum=0.12) + return self._vary_melodic_notes(notes, role, key, scale, section, total_length) + + notes = self.create_melody(key, scale, total_length, genre) + if role == 'arp': + notes = self._scale_note_lengths(notes, 0.45, minimum=0.1) + elif role == 'lead': + notes = self._transpose_notes(notes, 12) + elif role == 'counter': + sparse = [] + for note in notes: + start = float(note['start']) + if (start % 4.0) < 2.0: + continue + sparse.append(self._make_note(note['pitch'] - 12, start, max(0.2, float(note['duration']) * 0.8), max(50, int(note['velocity']) - 10))) + notes = sparse + notes = self._vary_melodic_notes(notes, role, key, scale, section, total_length) + if role in ['lead', 'arp', 'pluck', 'counter'] and kind in ['build', 'drop'] and total_length >= 8.0: + notes = self._merge_section_notes(notes, self._build_turnaround_notes(key, scale, total_length, 5, 84), total_length) + return notes + + def _render_fx_scene(self, role: str, key: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: + total_length = float(section['beats']) + kind = section.get('kind', 'drop') + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) + rng = self._section_rng(section, role, salt=19) + + if role == 'drone': + notes = [ + self._make_note(root_midi - 12, 0.0, min(total_length, 8.0 if kind == 'break' else total_length), 58), + self._make_note(root_midi - 5, max(0.0, total_length / 2.0), min(total_length / 2.0, 8.0), 52), + ] + if kind in ['build', 'drop'] and total_length >= 12.0: + notes.append(self._make_note(root_midi + 2, max(0.0, total_length - 6.0), 4.0, 48)) + return notes + + if role == 'reverse_fx': + if str(section.get('transition_fill', 'reverse')).lower() not in ['reverse', 'impact'] and kind not in ['break', 'build']: + return [] + notes = [] + for span, offset, velocity in ((4.0, 4.0, 70), (2.0, 2.0, 64), (1.0, 1.0, 58)): + if total_length >= offset: + start = max(0.0, total_length - offset) + notes.append(self._make_note(root_midi + 12, start, min(span, total_length - start), velocity)) + if kind == 'build' and total_length >= 16.0 and rng.random() > 0.35: + notes.append(self._make_note(root_midi + 7, max(0.0, total_length - 8.0), 1.5, 56)) + return notes + + if role == 'riser': + notes = [] + sweep_start = max(0.0, total_length - min(8.0, total_length)) + for offset, pitch, velocity in ((0.0, root_midi + 7, 64), (2.0, root_midi + 12, 70), (4.0, root_midi + 19, 74), (6.0, root_midi + 24, 78)): + start = sweep_start + offset + if start < total_length: + notes.append(self._make_note(pitch, start, min(2.0, total_length - start), velocity)) + if kind == 'build' and total_length >= 8.0: + notes.extend([ + self._make_note(root_midi + 12, max(0.0, total_length - 2.0), 0.5, 82), + self._make_note(root_midi + 19, max(0.0, total_length - 1.0), 0.45, 86), + ]) + return notes + + if role == 'impact': + if kind in ['intro', 'outro'] and str(section.get('transition_fill', 'impact')).lower() != 'impact': + return [] + notes = [self._make_note(root_midi + 7, 0.0, 0.5, 82)] + if total_length >= 8.0 and kind in ['build', 'drop']: + notes.append(self._make_note(root_midi + 12, total_length - 0.5, 0.45, 76)) + if kind == 'drop' and total_length >= 16.0 and rng.random() > 0.4: + notes.append(self._make_note(root_midi + 10, 8.0, 0.35, 72)) + return notes + + if role == 'atmos': + notes = [ + self._make_note(root_midi, 0.0, min(8.0, total_length), 54), + self._make_note(root_midi + 7, max(0.0, total_length / 2.0), min(8.0, total_length / 2.0), 50), + ] + if kind in ['intro', 'break', 'outro'] and total_length >= 12.0: + notes.append(self._make_note(root_midi + 12, max(0.0, total_length - 4.0), min(4.0, total_length), 46)) + return notes + + if role == 'vocal': + notes = [] + if kind == 'intro': + base_positions = [7.5, 15.5] + elif kind == 'build': + base_positions = [1.5, 3.5, 5.5, 7.5] + if total_length >= 16.0: + base_positions.extend([11.5, 13.5, 15.5]) + elif kind == 'drop': + base_positions = [1.5, 2.75, 5.5, 6.75] + if total_length >= 16.0: + base_positions.extend([9.5, 10.75, 13.5, 14.75]) + elif kind == 'break': + base_positions = [3.5, 11.5] + else: + base_positions = [1.5, 5.5] + + for index, pos in enumerate(base_positions): + if pos >= total_length: + continue + pitch = root_midi + (10 if kind == 'drop' and index % 2 else 3) + duration = 0.22 if kind == 'drop' else 0.3 + velocity = 80 if kind in ['build', 'drop'] else 72 + if rng.random() > 0.82: + pitch += 12 + notes.append(self._make_note(pitch, pos, duration, velocity)) + + if kind == 'build' and total_length >= 8.0: + notes.append(self._make_note(root_midi + 15, max(0.0, total_length - 0.75), 0.22, 84)) + return notes + + return [] + + def _build_pad_motion(self, key: str, scale_name: str, total_length: float, + octave: int = 4, sustain_beats: float = 4.0) -> List[Dict[str, Any]]: + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, octave) + scale_notes = self.get_scale_notes(root_midi, scale_name) + progression = random.choice(CHORD_PROGRESSIONS.get('techno' if 'm' in key.lower() else 'house', CHORD_PROGRESSIONS['techno'])) + notes = [] + bars = max(1, int(total_length / 4.0)) + + for bar in range(bars): + degree = progression[bar % len(progression)] - 1 + chord_root = scale_notes[degree % len(scale_notes)] + start = float(bar * 4.0) + duration = min(sustain_beats, total_length - start) + for interval in [0, 7, 12]: + notes.append(self._make_note(chord_root + interval, start, duration, 66)) + return notes + + def _generate_drum_tracks(self, genre: str, style: str) -> List[Dict]: + """Genera tracks de batería""" + tracks = [] + + # Kick siempre + tracks.append({ + 'name': 'Kick', + 'type': 'midi', + 'color': TRACK_COLORS['kick'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_kick_pattern(genre, style) + } + }) + + # Snare/Clap + tracks.append({ + 'name': 'Clap', + 'type': 'midi', + 'color': TRACK_COLORS['clap'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_clap_pattern(genre, style) + } + }) + + # Hi-hats + tracks.append({ + 'name': 'HiHat', + 'type': 'midi', + 'color': TRACK_COLORS['hat'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_hat_pattern(genre, style) + } + }) + + # Percusión extra para estilos más complejos + if style in ['latin', 'afro', 'groovy', 'complex']: + tracks.append({ + 'name': 'Percussion', + 'type': 'midi', + 'color': TRACK_COLORS['hat'], + 'clip': { + 'slot': 0, + 'length': 4.0, + 'notes': self._create_perc_pattern(genre, style) + } + }) + + return tracks + + def _generate_bass_track(self, key: str, scale: str, genre: str, style: str) -> Dict: + """Genera un track de bajo""" + notes = self.create_bassline(key, style, 16.0) + + return { + 'name': 'Bass', + 'type': 'midi', + 'color': TRACK_COLORS['bass'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_chord_track(self, key: str, scale: str, genre: str) -> Dict: + """Genera un track de acordes""" + notes = self.create_chord_progression(key, genre, 16.0) + + return { + 'name': 'Chords', + 'type': 'midi', + 'color': TRACK_COLORS['chords'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_lead_track(self, key: str, scale: str, genre: str) -> Dict: + """Genera un track lead/melódico""" + notes = self.create_melody(key, scale, 16.0, genre) + + return { + 'name': 'Lead', + 'type': 'midi', + 'color': TRACK_COLORS['synth'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': notes + } + } + + def _generate_fx_track(self) -> Dict: + """Genera un track de FX/Atmósfera""" + return { + 'name': 'FX', + 'type': 'midi', + 'color': TRACK_COLORS['fx'], + 'clip': { + 'slot': 0, + 'length': 16.0, + 'notes': self._create_fx_notes() + } + } + + # ========================================================================= + # PATRONES DE BATERÍA + # ========================================================================= + + def _create_kick_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de kick""" + notes = [] + + if style == 'minimal': + # Kick en 1 y 2.5 + for bar in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 120}) + notes.append({'pitch': 36, 'start': bar * 4.0 + 2.5, 'duration': 0.25, 'velocity': 110}) + elif style == 'four-on-the-floor' or genre in ['house', 'tech-house']: + # 4/4 clásico + for bar in range(4): + for beat in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) + else: # Default techno + for bar in range(4): + for beat in range(4): + vel = 127 if beat == 0 else 115 + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': vel}) + + return notes + + def _create_clap_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de clap/snare""" + notes = [] + + # Claps en 2 y 4 (beats 1 y 3 en 0-indexed) + for bar in range(4): + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.25, 'velocity': 110}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.25, 'velocity': 110}) + + # Snare adicional para DnB/Jungle + if genre == 'drum-and-bass': + for bar in range(4): + notes.append({'pitch': 38, 'start': bar * 4.0 + 1.75, 'duration': 0.1, 'velocity': 90}) + notes.append({'pitch': 38, 'start': bar * 4.0 + 2.25, 'duration': 0.1, 'velocity': 85}) + + return notes + + def _create_hat_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de hi-hats""" + notes = [] + + if style in ['minimal', 'dub']: + # Off-bats simples + for bar in range(4): + for beat in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) + elif style in ['tech-house-swing', 'jackin', 'swing', 'latin-tech-house']: + # MJ-02: Tech house swing hats - 16% swing on 1/8 notes + swing_offset = 0.04 # ~16% swing at 16th note level + for bar in range(4): + for beat in range(4): + # Straight 8th note + time_straight = bar * 4.0 + beat * 1.0 + notes.append({'pitch': 42, 'start': time_straight, 'duration': 0.1, 'velocity': 95}) + # Swung off-beat 8th + time_off = bar * 4.0 + beat + 0.5 + swing_offset + notes.append({'pitch': 42, 'start': time_off, 'duration': 0.1, 'velocity': 75}) + # Open hat at end of every other bar + if bar % 2 == 1: + notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.4, 'velocity': 80}) + elif style == 'tech-house-jackin': + # MJ-02: Denser hat pattern for jackin tech house + for bar in range(4): + for beat in range(4): + for sub in range(2): + time = bar * 4.0 + beat + sub * 0.5 + vel = 100 if sub == 0 else 80 + notes.append({'pitch': 42, 'start': time, 'duration': 0.08, 'velocity': vel}) + # 16th note fill in last beat + notes.append({'pitch': 42, 'start': bar * 4.0 + 3.75, 'duration': 0.05, 'velocity': 65}) + notes.append({'pitch': 46, 'start': bar * 4.0 + 2.5, 'duration': 0.5, 'velocity': 85}) + elif style == 'tech-house-minimal': + # MJ-02: Sparse, subtle hats for minimal tech house + for bar in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + 0.5, 'duration': 0.1, 'velocity': 80}) + notes.append({'pitch': 42, 'start': bar * 4.0 + 2.5, 'duration': 0.1, 'velocity': 70}) + if bar % 2 == 1: + notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.3, 'velocity': 60}) + else: + # 8vos con variación + for bar in range(4): + for beat in range(4): + for sub in range(2): + time = bar * 4.0 + beat + sub * 0.5 + vel = 90 if sub == 0 else 70 + notes.append({'pitch': 42, 'start': time, 'duration': 0.1, 'velocity': vel}) + + # Open hats ocasionales + if style not in ['minimal']: + for bar in range(4): + notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.5, 'velocity': 80}) + + return notes + + def _create_perc_pattern(self, genre: str, style: str) -> List[Dict]: + """Crea patrón de percusión extra""" + notes = [] + + if style in ['latin-tech-house', 'latin', 'latin-industrial']: + # MJ-05: Latin tech house percussion - congas/bongos + for bar in range(4): + # Conga pattern (high conga = pitch 50, low conga = pitch 43) + # Tumbao pattern + notes.append({'pitch': 50, 'start': bar * 4.0 + 0.5, 'duration': 0.15, 'velocity': 85}) + notes.append({'pitch': 50, 'start': bar * 4.0 + 2.5, 'duration': 0.15, 'velocity': 90}) + notes.append({'pitch': 43, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 75}) + notes.append({'pitch': 43, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 80}) + # Bongo accent + if bar % 2 == 0: + notes.append({'pitch': 48, 'start': bar * 4.0 + 1.5, 'duration': 0.1, 'velocity': 70}) + notes.append({'pitch': 48, 'start': bar * 4.0 + 3.5, 'duration': 0.1, 'velocity': 65}) + # Shaker layer + for i in range(8): + time = bar * 4.0 + i * 0.5 + if i % 2 == 1: + notes.append({'pitch': 53, 'start': time, 'duration': 0.05, 'velocity': 50 + random.randint(-5, 5)}) + else: + for bar in range(4): + # Shakers/congas en 16vos + for i in range(16): + time = bar * 4.0 + i * 0.25 + if i % 4 != 0: # Skip downbeats + vel = 60 + random.randint(-10, 10) + notes.append({'pitch': 37, 'start': time, 'duration': 0.1, 'velocity': vel}) + + return notes + + def _create_fx_notes(self) -> List[Dict]: + """Crea notas para FX/atmósfera""" + notes = [] + + # Swells y risers + for bar in [0, 2]: + # Nota larga ascendente + notes.append({'pitch': 84, 'start': bar * 4.0 + 3.0, 'duration': 1.0, 'velocity': 70}) + + return notes + + # ========================================================================= + # CREACIÓN DE PATRONES PARA MCP + # ========================================================================= + + def create_drum_pattern(self, style: str, pattern_type: str, length: float) -> List[Dict]: + """Crea un patrón de batería completo para usar con MCP""" + notes = [] + bars = int(length / 4.0) + + if pattern_type == 'kick-only': + for bar in range(bars): + for beat in range(4): + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) + + elif pattern_type == 'hats-only': + for bar in range(bars): + for beat in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) + + elif pattern_type == 'minimal': + for bar in range(bars): + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 127}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 110}) + notes.append({'pitch': 42, 'start': bar * 4.0 + 2.5, 'duration': 0.1, 'velocity': 80}) + + elif style == 'tech-house-swing': + # MJ-02: Tech house with swing - kick 1&3, ghost kicks on 2.5&3.5, swing hats, ghost clap + for bar in range(bars): + # Main kicks on 1 and 3 + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 127}) + notes.append({'pitch': 36, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 127}) + # Ghost kicks on 2.5 and 3.5 + notes.append({'pitch': 36, 'start': bar * 4.0 + 1.5, 'duration': 0.15, 'velocity': 95}) + notes.append({'pitch': 36, 'start': bar * 4.0 + 3.5, 'duration': 0.15, 'velocity': 90}) + # Clap on 2 and 4 with ghost note + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 110}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.85, 'duration': 0.08, 'velocity': 60}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 110}) + # Swing hats (16% swing) + swing = 0.04 + for beat in range(4): + notes.append({'pitch': 42, 'start': bar * 4.0 + beat, 'duration': 0.1, 'velocity': 90}) + notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5 + swing, 'duration': 0.1, 'velocity': 70}) + # Open hat + notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.4, 'velocity': 75}) + + elif style == 'tech-house-jackin': + # MJ-02: Jackin tech house - energetic, dense hats, harder clap + for bar in range(bars): + for beat in range(4): + vel = 127 if beat in [0, 2] else 105 + notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': vel}) + # Strong clap on 2 and 4 + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 120}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 120}) + # Dense 16th hats + for i in range(16): + time = bar * 4.0 + i * 0.25 + vel = 100 if i % 4 == 0 else 75 if i % 2 == 0 else 55 + notes.append({'pitch': 42, 'start': time, 'duration': 0.08, 'velocity': vel}) + # Open hat every bar + notes.append({'pitch': 46, 'start': bar * 4.0 + 2.5, 'duration': 0.5, 'velocity': 85}) + + elif style == 'tech-house-minimal': + # MJ-02: Minimal tech house - sparse kick, subtle perc + for bar in range(bars): + notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 120}) + if bar % 2 == 0: + notes.append({'pitch': 36, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 105}) + # Very sparse hats + notes.append({'pitch': 42, 'start': bar * 4.0 + 1.5, 'duration': 0.08, 'velocity': 65}) + notes.append({'pitch': 42, 'start': bar * 4.0 + 3.5, 'duration': 0.08, 'velocity': 60}) + # Subtle clap on 2 and 4 every other bar + if bar % 2 == 1: + notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.15, 'velocity': 80}) + notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.15, 'velocity': 80}) + + else: # full + notes.extend(self._create_kick_pattern(style, 'standard')) + notes.extend(self._create_clap_pattern(style, 'standard')) + notes.extend(self._create_hat_pattern(style, 'standard')) + + return notes + + return notes + + def create_bassline(self, key: str, style: str, length: float) -> List[Dict]: + """Crea una línea de bajo musical""" + notes = [] + + # Parsear key + root_note = key[:-1] if len(key) > 1 else key + is_minor = 'm' in key.lower() + scale_name = 'minor' if is_minor else 'major' + + root_midi = self.note_name_to_midi(root_note, 2) # Octava 2 para bajo + scale_notes = self.get_scale_notes(root_midi, scale_name) + + bars = int(length / 4.0) + + if style == 'tech-house': + # MJ-03: Tech house bass - syncopated, groovy with velocity variations + for bar in range(bars): + # Beat 1: root on downbeat + notes.append({'pitch': root_midi, 'start': bar * 4.0, 'duration': 0.3, 'velocity': 120}) + # Off-beat after beat 1: fifth or octave + fifth = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 + notes.append({'pitch': fifth, 'start': bar * 4.0 + 0.75, 'duration': 0.2, 'velocity': 85}) + # Beat 2: syncopated - skip beat 2, play on 2.5 + notes.append({'pitch': root_midi, 'start': bar * 4.0 + 2.25, 'duration': 0.25, 'velocity': 95}) + # Beat 3: root again + notes.append({'pitch': root_midi, 'start': bar * 4.0 + 3.0, 'duration': 0.3, 'velocity': 110}) + # Off-beat ghost + minor_third = scale_notes[2] if len(scale_notes) > 2 else root_midi + 3 + notes.append({'pitch': minor_third, 'start': bar * 4.0 + 3.5, 'duration': 0.15, 'velocity': 70}) + # Bar variation: every 2nd bar add extra syncopation + if bar % 2 == 1: + notes.append({'pitch': root_midi, 'start': bar * 4.0 + 1.5, 'duration': 0.15, 'velocity': 80}) + + elif style == 'rolling': + # Bass en 16vos + for bar in range(bars): + for beat in range(4): + for sub in range(4): + time = bar * 4.0 + beat + sub * 0.25 + if sub == 0: + pitch = root_midi + vel = 120 + elif sub == 2: + pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 + vel = 100 + else: + pitch = root_midi + vel = 80 if sub % 2 == 0 else 70 + + notes.append({'pitch': pitch, 'start': time, 'duration': 0.2, 'velocity': vel}) + + elif style == 'minimal': + # Solo en beats 1 y 3 + for bar in range(bars): + for beat in [0, 2]: + time = bar * 4.0 + beat + notes.append({'pitch': root_midi, 'start': time, 'duration': 1.5, 'velocity': 110}) + + elif style == 'offbeat': + # Notas en off-beats (house típico) + for bar in range(bars): + for beat in range(4): + time = bar * 4.0 + beat + 0.5 + pitch = root_midi if beat % 2 == 0 else scale_notes[3] + notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': 100}) + + elif style == 'acid': + # Estilo TB-303 con slides + for bar in range(bars): + for i in range(8): + time = bar * 4.0 + i * 0.5 + pitch = root_midi + random.choice([0, 3, 5, 7, 10]) + vel = 90 + random.randint(-20, 20) + notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': min(127, max(60, vel))}) + + else: # walking + for bar in range(bars): + for beat in range(4): + time = bar * 4.0 + beat + if beat == 0: + pitch = root_midi + elif beat == 1: + pitch = scale_notes[2] if len(scale_notes) > 2 else root_midi + 3 + elif beat == 2: + pitch = scale_notes[3] if len(scale_notes) > 3 else root_midi + 5 + else: + pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 + + notes.append({'pitch': pitch, 'start': time, 'duration': 0.9, 'velocity': 100}) + + return notes + + def create_chord_progression(self, key: str, progression_type: str, length: float) -> List[Dict]: + """Crea una progresión de acordes""" + notes = [] + + # Parsear key + root_note = key[:-1] if len(key) > 1 else key + is_minor = 'm' in key.lower() + scale_name = 'minor' if is_minor else 'major' + + root_midi = self.note_name_to_midi(root_note, 4) # Octava 4 para acordes + scale_notes = self.get_scale_notes(root_midi, scale_name) + + # Seleccionar progresión + progressions = CHORD_PROGRESSIONS.get(progression_type, CHORD_PROGRESSIONS['techno']) + progression = random.choice(progressions) + + bars = int(length / 4.0) + beats_per_bar = 4 + + for bar in range(bars): + degree = progression[bar % len(progression)] - 1 + + if degree < len(scale_notes): + chord_root = scale_notes[degree] + else: + chord_root = root_midi + + # Construir acorde (triada) + third = 3 if 'minor' in scale_name else 4 + chord_tones = [chord_root, chord_root + third, chord_root + 7] + + # Stab chords - cortos y percusivos + if progression_type == 'techno': + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar, + 'duration': 0.25, + 'velocity': 90 + }) + elif progression_type == 'house': + for beat in [0.5, 2.5]: + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar + beat, + 'duration': 0.5, + 'velocity': 75 + }) + else: + # Default: acordes en beats 1 y 3 + for beat in [0, 2]: + for pitch in chord_tones: + notes.append({ + 'pitch': pitch, + 'start': bar * beats_per_bar + beat, + 'duration': 1.0, + 'velocity': 85 + }) + + return notes + + def create_melody(self, key: str, scale: str, length: float, genre: str) -> List[Dict]: + """Crea una melodía/lead""" + notes = [] + + root_note = key[:-1] if len(key) > 1 else key + root_midi = self.note_name_to_midi(root_note, 5) # Octava 5 para lead + scale_notes = self.get_scale_notes(root_midi, scale) + + bars = max(1, int(length / 4.0)) + motif_pool = [ + ([0, 2, 4, 2, 5, 4], [0.0, 0.5, 1.5, 2.0, 2.75, 3.25]), + ([0, 3, 4, 6, 4], [0.0, 0.75, 1.5, 2.5, 3.25]), + ([0, 2, 3, 5, 3, 2], [0.0, 0.5, 1.0, 2.0, 2.5, 3.5]), + ] + motif_steps, motif_times = random.choice(motif_pool) + + for bar in range(bars): + bar_offset = bar * 4.0 + phrase_shift = 0 if bar % 4 in [0, 1] else random.choice([0, 1, -1, 2]) + invert_tail = (bar % 4 == 3) + for index, step in enumerate(motif_steps): + start = bar_offset + motif_times[index % len(motif_times)] + if start >= length: + continue + if invert_tail and index >= max(1, len(motif_steps) - 2): + start += 0.25 + if random.random() < 0.18 and index not in [0, len(motif_steps) - 1]: + continue + + scale_index = (step + phrase_shift) % len(scale_notes) + pitch = scale_notes[scale_index] + if genre in ['trance', 'progressive'] and index == len(motif_steps) - 1: + pitch += 12 + elif genre in ['techno', 'tech-house'] and index % 3 == 2: + pitch -= 12 + + duration = 0.22 if start % 1.0 not in [0.0, 0.5] else 0.35 + velocity = 78 + ((index + bar) % 3) * 8 + random.randint(-6, 8) + notes.append({ + 'pitch': pitch, + 'start': start, + 'duration': duration, + 'velocity': max(60, min(123, velocity)) + }) + + return notes + + # ========================================================================= + # Human Feel Integration + # ========================================================================= + + def apply_human_feel(self, config: Dict[str, Any], intensity: float = 0.6) -> Dict[str, Any]: + """ + Aplica herramientas de human feel a una configuración generada. + + Args: + config: Configuración del generador + intensity: Intensidad (0.3=sutil, 0.6=groove, 1.0=vivo) + + Returns: + Configuración con human feel aplicado + + Configuración por intensidad: + - 0.3 (sutil): Fades rápidos en intro/outro, LFO ±1.5dB, sparse fills, 8% swing + - 0.6 (groove): Fades en tutti, LFO ±3dB con S-curve, medium fills, 14% swing + - 1.0 (vivo): Fades agresivos con pump, LFO ±5dB, heavy fills, 18% swing + """ + import random + + # Configuración por intensidad + intensity_config = { + 0.3: { + 'fade_in_bars': 2.0, + 'fade_out_bars': 2.0, + 'lfo_depth': 1.5, + 'lfo_rate': 0.25, + 'fill_density': 'sparse', + 'swing_percent': 8.0, + 'sidechain_style': 'subtle', + }, + 0.6: { + 'fade_in_bars': 4.0, + 'fade_out_bars': 4.0, + 'lfo_depth': 3.0, + 'lfo_rate': 0.5, + 'fill_density': 'medium', + 'swing_percent': 14.0, + 'sidechain_style': 'jackin', + }, + 1.0: { + 'fade_in_bars': 6.0, + 'fade_out_bars': 6.0, + 'lfo_depth': 5.0, + 'lfo_rate': 0.75, + 'fill_density': 'heavy', + 'swing_percent': 18.0, + 'sidechain_style': 'jackin', + }, + } + + cfg = intensity_config.get(intensity, intensity_config[0.6]) + + # Aplicar swing a patrones MIDI + for track in config.get('tracks', []): + if track.get('type') == 'midi': + # Aplicar swing al pattern + pattern = track.get('pattern', []) + if pattern: + track['swing_percent'] = cfg['swing_percent'] + + # Agregar automation de volumen por sección + for track in config.get('tracks', []): + role = track.get('role', '') + if role in ['kick', 'bass', 'top_loop', 'synth_loop']: + # Automatización de volumen por sección + track['volume_automation'] = { + 'curve_type': 's_curve' if intensity >= 0.6 else 'linear', + 'section_map': { + 'intro': 0.5 + (0.35 * intensity), + 'build': 0.7 + (0.15 * intensity), + 'drop': 0.85 + (0.1 * intensity), + 'break': 0.6 + (0.2 * intensity), + 'outro': 0.4 + (0.2 * intensity), + } + } + + # Agregar sidechain pump para buses + if 'buses' in config: + for bus_name, bus in config['buses'].items(): + if bus_name in ['drums', 'bass', 'music']: + bus['sidechain_pump'] = { + 'intensity': intensity, + 'style': cfg['sidechain_style'], + 'attack': 0.001 * (1.0 / intensity), + 'release': 0.1 * intensity, + } + + # Agregar fills automáticos + for track in config.get('tracks', []): + if track.get('type') == 'midi' and track.get('role') in ['kick', 'snare', 'hat']: + track['pattern_fills'] = { + 'density': cfg['fill_density'], + 'section': 'all', + 'fill_probability': 0.125 * (1 + intensity), # 1 cada 8-4 bars + } + + # Agregar variación de loops por sección + for track in config.get('tracks', []): + if track.get('type') == 'audio' and track.get('role'): + track['loop_variation'] = { + 'intro': 'filtered' if intensity >= 0.3 else 'standard', + 'build': 'building' if intensity >= 0.6 else 'standard', + 'drop': 'full' if intensity >= 0.6 else 'standard', + 'break': 'sparse' if intensity >= 0.3 else 'standard', + 'outro': 'fading' if intensity >= 0.6 else 'standard', + } + + return config + + def generate_with_human_feel(self, genre: str, style: str, bpm: float, key: str, + structure: str, intensity: float = 0.6) -> Dict[str, Any]: + """ + Genera una configuración completa con human feel aplicado. + + Args: + genre: Género musical + style: Sub-estilo + bpm: BPM + key: Tonalidad + structure: Estructura ('standard', 'club', 'tech-house-dj') + intensity: Intensidad de human feel (0.3-1.0) + + Returns: + Configuración completa con human feel + """ + # Generar configuración base + config = self.generate_config(genre, style, bpm, key, structure) + + # Aplicar human feel + config = self.apply_human_feel(config, intensity) + + return config + diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py new file mode 100644 index 0000000..b1823d2 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import argparse +import gzip +import json +from collections import Counter +from pathlib import Path +import xml.etree.ElementTree as ET + + +def _node_name(node: ET.Element | None) -> str: + if node is None: + return "" + for tag in ("EffectiveName", "UserName", "Name"): + child = node.find(tag) + if child is not None: + value = child.attrib.get("Value", "") + if value: + return value + return node.attrib.get("Value", "") + + +def _device_name(device: ET.Element) -> str: + if device.tag == "PluginDevice": + info = device.find("PluginDesc/VstPluginInfo") + if info is None: + info = device.find("PluginDesc/AuPluginInfo") + if info is not None: + plug = info.find("PlugName") + if plug is not None and plug.attrib.get("Value"): + return plug.attrib["Value"] + return device.tag + + +def _session_clip_count(track: ET.Element) -> int: + count = 0 + for slot in track.findall("./DeviceChain/MainSequencer/ClipSlotList/ClipSlot"): + if slot.find("Value/MidiClip") is not None or slot.find("Value/AudioClip") is not None: + count += 1 + return count + + +def _arrangement_clip_count(track: ET.Element) -> int: + return len(track.findall(".//MainSequencer//MidiClip")) + len( + track.findall(".//MainSequencer//AudioClip") + ) + + +def _tempo_value(live_set: ET.Element) -> float | None: + node = live_set.find(".//Tempo/Manual") + if node is None: + return None + try: + return float(node.attrib.get("Value", "0")) + except ValueError: + return None + + +def _locator_summary(live_set: ET.Element) -> list[dict[str, float | str | None]]: + locators: list[tuple[float, str]] = [] + for locator in live_set.findall(".//Locators/Locators/Locator"): + try: + time = float(locator.find("Time").attrib.get("Value", "0")) + except (AttributeError, ValueError): + time = 0.0 + name = _node_name(locator.find("Name")) + locators.append((time, name)) + locators.sort(key=lambda item: item[0]) + summary: list[dict[str, float | str | None]] = [] + for index, (time, name) in enumerate(locators): + next_time = locators[index + 1][0] if index + 1 < len(locators) else None + summary.append( + { + "time_beats": time, + "name": name, + "section_length_beats": None if next_time is None else next_time - time, + } + ) + return summary + + +def _arrangement_length_beats(root: ET.Element) -> float: + max_end = 0.0 + for clip in root.findall(".//MidiClip") + root.findall(".//AudioClip"): + current_end = clip.find("CurrentEnd") + start = clip.attrib.get("Time") + if current_end is None or start is None: + continue + try: + end = float(start) + float(current_end.attrib.get("Value", "0")) + except ValueError: + continue + max_end = max(max_end, end) + return max_end + + +def analyze_set(als_path: Path) -> dict: + with gzip.open(als_path, "rb") as handle: + root = ET.parse(handle).getroot() + live_set = root.find("LiveSet") + if live_set is None: + raise ValueError(f"Invalid ALS file: {als_path}") + + tracks = list(live_set.find("Tracks") or []) + track_summaries = [] + device_counter: Counter[str] = Counter() + + for track in tracks: + devices = track.findall("./DeviceChain/DeviceChain/Devices/*") + device_names = [_device_name(device) for device in devices] + device_counter.update(device_names) + track_summaries.append( + { + "type": track.tag, + "name": _node_name(track.find("Name")), + "group_id": track.find("TrackGroupId").attrib.get("Value", "") + if track.find("TrackGroupId") is not None + else "", + "session_clip_count": _session_clip_count(track), + "arrangement_clip_count": _arrangement_clip_count(track), + "devices": device_names, + } + ) + + automation_events = 0 + for automation in root.findall(".//ArrangerAutomation"): + automation_events += len(automation.findall(".//FloatEvent")) + automation_events += len(automation.findall(".//EnumEvent")) + automation_events += len(automation.findall(".//BoolEvent")) + + return { + "file": str(als_path), + "tempo": _tempo_value(live_set), + "track_type_counts": dict(Counter(track.tag for track in tracks)), + "scene_count": len(live_set.findall("./SceneNames/Scene")), + "locators": _locator_summary(live_set), + "arrangement_length_beats": _arrangement_length_beats(root), + "automation_event_count": automation_events, + "top_devices": dict(device_counter.most_common(16)), + "tracks": track_summaries, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Analyze Ableton .als templates.") + parser.add_argument("path", nargs="?", default=".", help="Folder containing .als files") + parser.add_argument("--json", action="store_true", help="Emit JSON") + args = parser.parse_args() + + base = Path(args.path).resolve() + results = [analyze_set(path) for path in sorted(base.rglob("*.als"))] + + if args.json: + print(json.dumps(results, indent=2)) + return + + for result in results: + print(f"=== {Path(result['file']).name} ===") + print(f"tempo: {result['tempo']}") + print(f"tracks: {result['track_type_counts']}") + print(f"scenes: {result['scene_count']}") + print(f"arrangement_length_beats: {result['arrangement_length_beats']}") + print(f"automation_event_count: {result['automation_event_count']}") + print("locators:") + for locator in result["locators"]: + print( + f" - {locator['time_beats']:>6} {locator['name']}" + f" len={locator['section_length_beats']}" + ) + print("top_devices:") + for name, count in result["top_devices"].items(): + print(f" - {name}: {count}") + print() + + +if __name__ == "__main__": + main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py new file mode 100644 index 0000000..99572c2 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py @@ -0,0 +1,452 @@ +import os +import json +import logging +import argparse +from pathlib import Path +from typing import List, Dict, Tuple, Optional +from multiprocessing import Pool, cpu_count +import functools + +try: + from sentence_transformers import SentenceTransformer + from sklearn.metrics.pairwise import cosine_similarity + import numpy as np + HAS_ML = True +except ImportError: + HAS_ML = False + +# Import AudioAnalyzer for spectral analysis +try: + from audio_analyzer import AudioAnalyzer, analyze_sample + HAS_AUDIO_ANALYZER = True +except ImportError: + HAS_AUDIO_ANALYZER = False + +logger = logging.getLogger("VectorManager") +logging.basicConfig(level=logging.INFO) + + +# Global analyzer for multiprocessing workers (initialized once per worker) +_worker_analyzer = None + +def _init_worker(): + """Initialize the audio analyzer for each worker process.""" + global _worker_analyzer + if HAS_AUDIO_ANALYZER: + try: + _worker_analyzer = AudioAnalyzer(backend="auto") + except Exception: + _worker_analyzer = None + +def _process_single_file(args): + """ + Process a single audio file and return its metadata. + Used for multiprocessing parallel execution. + """ + f, library_dir, skip_audio_analysis = args + f = Path(f) + + import soundfile as sf + + # Clean up the name for better semantic understanding + name = f.stem + name_lower = name.lower() + clean_name = name.replace('_', ' ').replace('-', ' ').lower() + + # Keywords that strongly suggest a full song/mix + full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'} + + # Extract duration + duration = 0.0 + try: + info = sf.info(str(f)) + duration = info.duration + except Exception: + duration = -1.0 + + # Detect if it's likely a full song based on name and duration + is_full_song = False + if duration > 45.0: + is_full_song = True + elif any(kw in name_lower for kw in full_song_keywords) and duration > 30.0: + is_full_song = True + + # Spectral analysis with AudioAnalyzer + key = None + key_confidence = 0.0 + spectral_centroid = None + is_harmonic = None + + global _worker_analyzer + if not skip_audio_analysis and _worker_analyzer is not None: + try: + features = _worker_analyzer.analyze(str(f)) + key = features.key + key_confidence = features.key_confidence + spectral_centroid = features.spectral_centroid + is_harmonic = features.is_harmonic + except Exception: + pass + + # Use relative path as part of the context + try: + rel_path = f.relative_to(library_dir) + parts = rel_path.parts[:-1] + path_context = " ".join(parts).lower() + except ValueError: + path_context = "" + + description = f"{clean_name} {path_context}" + + metadata = { + 'path': str(f), + 'name': name, + 'description': description, + 'duration': duration, + 'is_full_song': is_full_song, + 'key': key, + 'key_confidence': key_confidence, + 'spectral_centroid': spectral_centroid, + 'is_harmonic': is_harmonic + } + + return metadata, description + +class VectorManager: + def __init__(self, library_dir: str, skip_audio_analysis: bool = False): + self.library_dir = Path(library_dir) + self.index_file = self.library_dir / ".sample_embeddings.json" + self.skip_audio_analysis = skip_audio_analysis + + self.model = None + self.embeddings = [] + self.metadata = [] + + # Audio analyzer instance for spectral analysis + self._audio_analyzer: Optional[AudioAnalyzer] = None + if HAS_AUDIO_ANALYZER and not skip_audio_analysis: + try: + self._audio_analyzer = AudioAnalyzer(backend="auto") + logger.info("AudioAnalyzer initialized for spectral analysis") + except Exception as e: + logger.warning(f"Failed to initialize AudioAnalyzer: {e}") + self._audio_analyzer = None + + if HAS_ML: + try: + # Load a very lightweight model for fast embeddings + logger.info("Loading sentence-transformers model (all-MiniLM-L6-v2)...") + self.model = SentenceTransformer('all-MiniLM-L6-v2') + except Exception as e: + logger.error(f"Failed to load embedding model: {e}") + + self._load_or_build_index() + + def _get_library_fingerprint(self) -> Dict: + """Compute a fingerprint of the library directory for change detection (BF-02/MJ-07).""" + extensions = {'.wav', '.aif', '.aiff', '.mp3'} + file_count = 0 + latest_mtime = 0.0 + try: + for ext in extensions: + for f in self.library_dir.rglob('*' + ext): + file_count += 1 + try: + mtime = f.stat().st_mtime + if mtime > latest_mtime: + latest_mtime = mtime + except OSError: + pass + for f in self.library_dir.rglob('*' + ext.upper()): + file_count += 1 + try: + mtime = f.stat().st_mtime + if mtime > latest_mtime: + latest_mtime = mtime + except OSError: + pass + except Exception: + pass + return {'file_count': file_count, 'latest_mtime': latest_mtime} + + def _load_or_build_index(self): + if self.index_file.exists(): + logger.info("Loading existing vector index...") + try: + with open(self.index_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.metadata = data.get('metadata', []) + + # BF-02/MJ-07: Check library fingerprint for auto-rebuild + stored_fp = data.get('library_fingerprint', {}) + current_fp = self._get_library_fingerprint() + stored_count = stored_fp.get('file_count', 0) + current_count = current_fp.get('file_count', 0) + if current_count != stored_count and stored_count > 0: + logger.info(f"Library changed ({stored_count} -> {current_count} files). Rebuilding index...") + self._build_index() + return + + if HAS_ML and 'embeddings' in data: + self.embeddings = np.array(data['embeddings']) + else: + logger.warning("No embeddings found in loaded index.") + except Exception as e: + logger.error(f"Failed to load index: {e}") + self._build_index() + else: + self._build_index() + + def _build_index(self): + logger.info(f"Scanning library {self.library_dir} for new embeddings...") + extensions = {'.wav', '.aif', '.aiff', '.mp3'} + + files_to_process = [] + for ext in extensions: + files_to_process.extend(self.library_dir.rglob('*' + ext)) + files_to_process.extend(self.library_dir.rglob('*' + ext.upper())) + + if not files_to_process: + logger.warning(f"No audio files found in {self.library_dir} to embed.") + return + + # Get unique files + unique_files = list(set(str(f) for f in files_to_process)) + total_files = len(unique_files) + logger.info(f"Found {total_files} audio files to process") + + # Determine number of workers (use 50% of available CPUs) + num_workers = max(1, cpu_count() // 2) + logger.info(f"Using {num_workers} CPU cores for parallel processing (50% capacity)") + + # Prepare arguments for parallel processing + args_list = [(f, str(self.library_dir), self.skip_audio_analysis) for f in unique_files] + + # Process files in parallel using multiprocessing + texts_to_embed = [] + self.metadata = [] + + if not self.skip_audio_analysis and HAS_AUDIO_ANALYZER: + # Use multiprocessing with audio analysis + logger.info("Starting parallel audio analysis...") + with Pool(processes=num_workers, initializer=_init_worker) as pool: + results = pool.map(_process_single_file, args_list) + + for metadata, description in results: + self.metadata.append(metadata) + texts_to_embed.append(description) + else: + # Fallback to sequential processing (no audio analysis) + logger.info("Processing files sequentially (audio analysis disabled)...") + import soundfile as sf + full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'} + + for i, f in enumerate(unique_files): + f = Path(f) + if (i + 1) % max(1, total_files // 20) == 0 or (i + 1) == total_files: + logger.info(f"Processing files: {i+1}/{total_files} ({(i+1)/total_files*100:.1f}%)") + + name = f.stem + clean_name = name.replace('_', ' ').replace('-', ' ').lower() + + duration = 0.0 + try: + info = sf.info(str(f)) + duration = info.duration + except Exception: + duration = -1.0 + + is_full_song = duration > 45.0 + + try: + rel_path = f.relative_to(self.library_dir) + path_context = " ".join(rel_path.parts[:-1]).lower() + except ValueError: + path_context = "" + + description = f"{clean_name} {path_context}" + texts_to_embed.append(description) + + self.metadata.append({ + 'path': str(f), + 'name': name, + 'description': description, + 'duration': duration, + 'is_full_song': is_full_song, + 'key': None, + 'key_confidence': 0.0, + 'spectral_centroid': None, + 'is_harmonic': None + }) + + if HAS_ML and self.model: + logger.info(f"Generating vectors for {len(texts_to_embed)} samples. This might take a moment...") + embeddings = self.model.encode(texts_to_embed) + self.embeddings = embeddings + + # BF-02: Save fingerprint alongside embeddings for auto-rebuild detection + fingerprint = self._get_library_fingerprint() + + # Save the vectors + with open(self.index_file, 'w', encoding='utf-8') as f: + json.dump({ + 'metadata': self.metadata, + 'embeddings': embeddings.tolist(), + 'library_fingerprint': fingerprint + }, f) + logger.info(f"Saved {len(self.metadata)} embeddings to {self.index_file}.") + else: + logger.error("ML libraries not installed. Run 'pip install sentence-transformers scikit-learn numpy'") + + # MJ-06: Genre keyword expansion for richer semantic search + GENRE_SEARCH_TERMS = { + 'tech-house': ['groovy', 'driving', 'punchy', 'jackin', 'swinging', 'hypnotic', 'bouncy'], + 'house': ['deep', 'soulful', 'warm', 'classic', 'funky'], + 'techno': ['industrial', 'dark', 'raw', 'hypnotic', 'peak-time', 'acid'], + 'trance': ['uplifting', 'ethereal', 'driving', 'euphoric'], + 'deep-house': ['deep', 'chill', 'smooth', 'laidback', 'warm'], + 'minimal': ['minimal', 'sparse', 'subtle', 'clean'], + 'drum-and-bass': ['heavy', 'dark', 'neuro', 'rolling', 'aggressive'], + } + + def enrich_query_with_genre(self, query: str, genre: str = "") -> str: + """MJ-06: Enrich a search query with genre-specific terms.""" + genre_lower = (genre or "").lower().strip() + terms = self.GENRE_SEARCH_TERMS.get(genre_lower, []) + if terms: + # Pick 2 random genre terms to enrich without overwhelming + import random as _rng + picked = _rng.sample(terms, min(2, len(terms))) + enriched = f"{query} {' '.join(picked)}" + logger.info(f"Enriched query for '{genre_lower}': '{query}' -> '{enriched}'") + return enriched + return query + + def semantic_search(self, query: str, limit: int = 5, max_duration: float = 0.0, genre: str = "") -> List[Dict]: + """ + Returns a list of metadata dicts sorted by semantic relevance down to the limit. + Fallback to basic substring matching if ML is unavailable. + + Args: + query: Semantic search terms + limit: Max results to return + max_duration: If > 0, filter out samples longer than this value + genre: Optional genre to enrich the search query (MJ-06) + """ + if not HAS_ML or self.model is None or len(self.embeddings) == 0: + logger.warning("ML unavailable, falling back to substring search.") + return self._fallback_search(query, limit, max_duration) + + # MJ-06: Enrich query with genre terms + effective_query = self.enrich_query_with_genre(query, genre) if genre else query + + logger.info(f"Performing semantic search for: '{effective_query}' (max_duration={max_duration})") + query_emb = self.model.encode([effective_query]) + + # Calculate cosine similarity between query and all stored embeddings + similarities = cosine_similarity(query_emb, self.embeddings)[0] + + # Apply duration and full-song penalties/filtering + adjusted_similarities = similarities.copy() + + for i, meta in enumerate(self.metadata): + # Filter out if it exceeds max_duration (if specified) + if max_duration > 0 and (meta.get('duration', 0) > max_duration or meta.get('duration', 0) < 0): + adjusted_similarities[i] = -1.0 + continue + + # Filter out explicit full songs + if meta.get('is_full_song', False) and max_duration > 0: + adjusted_similarities[i] = -1.0 + continue + + # Small penalty for longer samples if no max_duration specified + # to prioritize snippets over loops + if max_duration == 0 and meta.get('duration', 0) > 10.0: + adjusted_similarities[i] *= 0.9 + + # Get top indices from adjusted scores + top_indices = np.argsort(adjusted_similarities)[::-1][:limit] + + results = [] + for idx in top_indices: + score = float(adjusted_similarities[idx]) + if score < 0: # All remaining candidates are invalid + break + + meta = self.metadata[idx].copy() + meta['score'] = score + results.append(meta) + + return results + + def _fallback_search(self, query: str, limit: int = 5, max_duration: float = 0.0) -> List[Dict]: + query = query.lower() + scored = [] + for m in self.metadata: + # Duration filter + if max_duration > 0 and (m.get('duration', 0) > max_duration or m.get('duration', 0) < 0): + continue + if m.get('is_full_song', False) and max_duration > 0: + continue + + score = 0 + if query in m['name'].lower(): + score += 10 + if query in m['description'].lower(): + score += 5 + + if score > 0: + scored.append((score, m)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [m for s, m in scored[:limit]] + +if __name__ == "__main__": + import sys + import argparse + + parser = argparse.ArgumentParser(description="Vector Manager for sample library indexing") + parser.add_argument("library_dir", nargs='?', help="Path to the sample library directory") + parser.add_argument("search_query", nargs='?', help="Optional search query to test") + parser.add_argument("--skip-audio-analysis", action="store_true", + help="Skip spectral audio analysis for faster rebuild (development mode)") + parser.add_argument("--rebuild", action="store_true", + help="Force rebuild of the index from scratch") + + args = parser.parse_args() + + if args.library_dir: + # Check if index exists and rebuild flag is set + index_file = Path(args.library_dir) / ".sample_embeddings.json" + if args.rebuild and index_file.exists(): + logger.info(f"Removing existing index for rebuild: {index_file}") + index_file.unlink() + + vm = VectorManager(args.library_dir, skip_audio_analysis=args.skip_audio_analysis) + + if args.search_query: + res = vm.semantic_search(args.search_query) + print(f"Search Results for '{args.search_query}':") + for r in res: + print(f" Score: {r['score']:.3f}") + print(f" Name: {r['name']}") + print(f" Path: {r['path']}") + print(f" Key: {r.get('key', 'N/A')} (confidence: {r.get('key_confidence', 0):.2f})") + print(f" Spectral Centroid: {r.get('spectral_centroid', 'N/A')}") + print(f" Is Harmonic: {r.get('is_harmonic', 'N/A')}") + print() + else: + # Print summary of the loaded index + print(f"\nIndex Summary:") + print(f" Total samples: {len(vm.metadata)}") + # Count samples with spectral data + with_key = sum(1 for m in vm.metadata if m.get('key') is not None) + with_centroid = sum(1 for m in vm.metadata if m.get('spectral_centroid') is not None) + print(f" Samples with key detected: {with_key}") + print(f" Samples with spectral centroid: {with_centroid}") + else: + print("Usage: python vector_manager.py [search_query] [--skip-audio-analysis] [--rebuild]") + print("\nOptions:") + print(" --skip-audio-analysis Skip spectral analysis for faster rebuild") + print(" --rebuild Force rebuild index from scratch") diff --git a/AbletonMCP_AI_BAK_20260328_200801/README.md b/AbletonMCP_AI_BAK_20260328_200801/README.md new file mode 100644 index 0000000..e5570dc --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/README.md @@ -0,0 +1,222 @@ +# AbletonMCP-AI + +Sistema hibrido para controlar Ableton Live 12 desde MCP y generar proyectos musicales complejos, orientados a Arrangement View. + +Combina: + +- un Remote Script dentro de Live +- un servidor MCP en Python (52+ tools) +- seleccion de samples desde biblioteca local con busqueda semantica ML +- reconstruccion guiada por referencias +- fallback de audio y capas MIDI/instrumentos +- buses, returns y snapshots de mezcla por seccion +- mezcla harmonica Camelot wheel para DJ sets +- generacion Tech House DJ-ready con intro/outro extendidas + +Esta es la snapshot del proyecto al 2026-03-28. + +## Estado actual + +El sistema ya puede: + +- generar proyectos completos en Arrangement View con samples de la biblioteca local +- crear estructura, tracks, scenes, cue points y guide track +- combinar MIDI, instrumentos stock y audio de biblioteca local (827 samples indexados) +- analizar un track de referencia y reconstruir un resultado original inspirado en ese material +- materializar capas `AUDIO ...` con samples reales (kick, bass, synth, vocal, FX, etc.) +- aplicar snapshots por seccion a tracks y returns durante el commit Session -> Arrangement +- operar con returns desde el runtime y desde el MCP +- buses de mezcla (DRUM BUS, BASS BUS, MUSIC WIDE, VOCAL BUS, FX WASH) +- capas derivadas (RESAMPLE REVERSE FX, RISER, DOWNLIFTER, STUTTER) +- generar estructuras DJ-ready con intro/outro de 32 compases para beatmatching +- mezcla harmonica con Camelot wheel (compatible keys, sugerencias de transicion) +- auto-descubrir tracks de referencia desde `librerias/reference/` +- previsualizar blueprints sin crear nada en Ableton +- regenerar secciones individuales +- persistir historia de generaciones y diversidad de samples entre sesiones +- busqueda semantica enriquecida por genero (tech-house, house, techno, trance, etc.) +- auto-reindexar la biblioteca cuando cambian los archivos +- validar automaticamente el set post-generacion + +## Arquitectura resumida + +1. `__init__.py` + Remote Script principal. Vive dentro de Ableton, abre el socket TCP y ejecuta comandos sobre la API de Live. +2. `MCP_Server/server.py` + Servidor MCP/FastMCP. Expone tools, normaliza aliases y habla con el Remote Script. +3. `MCP_Server/song_generator.py` + Generador musical. Construye blueprint de tracks, sections, performance, locators y returns. +4. `MCP_Server/reference_listener.py` + Escucha el audio de referencia y arma un plan de reconstruccion usando la biblioteca local. +5. `MCP_Server/sample_manager.py`, `sample_selector.py`, `audio_analyzer.py` + Indexado, busqueda, scoring y analisis de samples. +6. `MaxForLive/` + Devices `.amxd` para la ruta hibrida con M4L. + +## Layout del repo + +```text +AbletonMCP_AI/ +|-- __init__.py +|-- Remote_Script.py +|-- start_server.bat +|-- .mcp.json +|-- README.md +|-- CLAUDE.md +|-- MaxForLive/ +| |-- AbletonMCP_Engine.amxd +| |-- AbletonMCP_Engine.maxpat +| `-- AbletonMCP_SamplerPro.amxd +|-- MCP_Server/ +| |-- server.py +| |-- song_generator.py +| |-- reference_listener.py +| |-- audio_analyzer.py +| |-- sample_manager.py +| |-- sample_selector.py +| |-- sample_index.py +| |-- socket_smoke_test.py +| |-- template_analyzer.py +| |-- ABLETUNES_TEMPLATE_NOTES.md +| `-- requirements.txt +`-- docs/ + |-- AI_HANDOFF.md + |-- ARCHITECTURE.md + |-- GPU_DIRECTML.md + |-- MCP_TOOLS.md + |-- PROJECT_CONTEXT.md + |-- REMOTE_PROTOCOL.md + `-- SETUP_WINDOWS.md +``` + +## Documentacion + +Leer primero: + +- [CLAUDE.md](CLAUDE.md) - handoff amplio, cronologia completa, estado real, paths y notas operativas +- [AI_HANDOFF](docs/AI_HANDOFF.md) - handoff corto y operativo +- [PROJECT_CONTEXT](docs/PROJECT_CONTEXT.md) - direccion de producto y lecciones aprendidas + +- [Arquitectura](docs/ARCHITECTURE.md) +- [Setup en Windows + Ableton](docs/SETUP_WINDOWS.md) +- [Tools MCP](docs/MCP_TOOLS.md) +- [Protocolo del Remote Script](docs/REMOTE_PROTOCOL.md) +- [GPU DirectML](docs/GPU_DIRECTML.md) +- [Notas del analisis de templates Abletunes](MCP_Server/ABLETUNES_TEMPLATE_NOTES.md) + +## Quick start + +### 1. Clonar y colocar en la carpeta de Ableton + +```powershell +# Clonar el repo +git clone https://gitea.cbcren.online/renato97/ableton-mcp-ai.git + +# Copiar a la carpeta de MIDI Remote Scripts +cp -r ableton-mcp-ai "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI" +``` + +### 2. Instalar dependencias Python + +```powershell +cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" +python -m pip install -r requirements.txt +``` + +### 3. Seleccionar el Control Surface en Live + +- Abrir Ableton Live 12. +- Ir a `Preferences > Link/Tempo/MIDI`. +- Elegir `AbletonMCP_AI` como `Control Surface`. + +### 4. Levantar el servidor MCP + +```powershell +cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI" +python MCP_Server/server.py +``` + +O: + +```powershell +start_server.bat +``` + +### 5. Probar conexion + +```powershell +cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" +python socket_smoke_test.py +``` + +## Ubicaciones externas esperadas + +Este repo no incluye bibliotecas pesadas ni material generado. El stack espera estos recursos fuera del repo: + +- biblioteca principal de samples (organizada por categorias): + `..\librerias\organized_samples` (827 samples indexados) +- biblioteca raw original: + `..\librerias\all_tracks` +- vector store para matching ML: + `..\librerias\vector_store` +- Ableton User Library para instalar el sampler M4L: + `%USERPROFILE%\Documents\Ableton\User Library` +- referencias MP3/WAV que se quieran analizar: + `..\sample` +- proyectos `.als`, renders y stems + +## Flujo recomendado + +1. Resetear el set. +2. Generar un track desde MCP o por socket. +3. Validar que el commit termine en Arrangement View. +4. Revisar audio tracks `AUDIO ...` y returns. +5. Ajustar perfiles, matching y snapshots. + +## Comandos utiles + +Generacion completa: + +```text +generate_track(genre="tech-house", style="latin-industrial", bpm=0, key="", structure="standard") +generate_track(genre="tech-house", style="groovy", bpm=126, key="Am", structure="tech-house-dj") +generate_song(genre="tech-house", style="latin-industrial", bpm=0, key="", structure="club") +``` + +DJ / Harmonic mixing: + +```text +get_harmonic_keys(key="Am") +get_compatible_keys(key="Am") +export_stems_config() +discover_reference_track() +get_reference_suggestions() +``` + +Utilidades de generacion: + +```text +preview_generation(genre="tech-house", style="groovy", bpm=126, key="Am", structure="tech-house-dj") +regenerate_section(section_name="DROP A") +get_generation_history() +``` + +Transporte: + +```text +start_playback() +stop_playback() +set_tempo(126) +``` + +Samples: + +```text +search_samples("kick", category="kick", limit=10) +advanced_search_samples(query="vocal", category="vocals", bpm=128, key="F#m") +analyze_audio_file("C:\\ruta\\track.mp3") +``` + +## Licencia + +Sin licencia publicada por ahora. Tratar este repo como privado/interno hasta definirla. diff --git a/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py b/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py new file mode 100644 index 0000000..d78f91c --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py @@ -0,0 +1,943 @@ +""" +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 + } + } diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json new file mode 100644 index 0000000..285238e --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json @@ -0,0 +1,14 @@ +{ + "implementer": { + "description": "Implements the requested code changes with minimal diff.", + "prompt": "You are a focused implementation worker. Make the requested code changes, keep the diff small, and do not overclaim." + }, + "verifier": { + "description": "Runs validations and checks whether the claimed work is actually complete.", + "prompt": "You are a strict verifier. Run the requested validations, compare code against claims, and report gaps clearly." + }, + "reporter": { + "description": "Writes the final worker report truthfully.", + "prompt": "You are a truthful technical reporter. Summarize only what was actually changed and verified." + } +} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json new file mode 100644 index 0000000..65aac2f --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json @@ -0,0 +1,30 @@ +{ + "planner": { + "description": "Breaks the task into a small, realistic execution plan and identifies the critical path.", + "prompt": "You are the planning agent. Read the task, identify the minimum safe plan, and tell the team what to implement first. Keep the plan concrete and short." + }, + "implementer_core": { + "description": "Implements the main code changes with a minimal diff.", + "prompt": "You are the core implementation agent. Make the requested code changes with the smallest coherent diff. Do not overclaim." + }, + "implementer_aux": { + "description": "Implements helper scripts, manifests, reports, and offline tooling.", + "prompt": "You are the auxiliary implementation agent. Focus on CLI helpers, manifests, reports, and utility scripts. Keep changes isolated." + }, + "validator": { + "description": "Runs validations and checks whether the implementation actually works.", + "prompt": "You are the validation agent. Run the required validations, inspect failures carefully, and report only what really passed." + }, + "retrieval_reviewer": { + "description": "Reviews retrieval/indexing logic for role contamination, cache compatibility, and data-shape issues.", + "prompt": "You are the retrieval reviewer. Inspect role safety, cache compatibility, manifests, and offline retrieval quality. Flag contamination and schema mismatches." + }, + "runtime_guard": { + "description": "Protects the Ableton runtime and blocks risky unrelated changes.", + "prompt": "You are the runtime guard. Prevent unnecessary edits to the Remote Script, runtime socket behavior, or generation path when the task does not require it." + }, + "reporter": { + "description": "Writes the final task report truthfully and concisely.", + "prompt": "You are the reporting agent. Write a technical report that only claims what was truly changed and verified." + } +} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 new file mode 100644 index 0000000..f34456c --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 @@ -0,0 +1,94 @@ +param( + [Parameter(Mandatory = $true)] + [string]$TaskFile, + + [Parameter(Mandatory = $true)] + [string]$ReportFile, + + [Parameter(Mandatory = $true)] + [string]$ProjectRoot, + + [Parameter(Mandatory = $true)] + [string]$OutputFile, + + [string]$CodexModel = "" +) + +$ErrorActionPreference = "Stop" + +function Resolve-CodexCommand() { + $cmd = Get-Command "codex.cmd" -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $fallback = Get-Command "codex" -ErrorAction SilentlyContinue + if ($fallback) { + return $fallback.Source + } + + throw "Command not found: codex" +} + +$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path +$reportPath = (Resolve-Path -LiteralPath $ReportFile).Path +$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path +$outputPath = [System.IO.Path]::GetFullPath($OutputFile) +$codexCommand = Resolve-CodexCommand + +$reviewPrompt = @" +Read this worker task file: +$taskPath + +Read this GLM report: +$reportPath + +Your job: +1. Inspect the real diff in the repository. +2. Verify whether GLM actually implemented what the report claims. +3. Fix anything incorrect, incomplete, or unsafe. +4. Run the relevant validations mentioned by the task/report. +5. Leave the repository in the best corrected state you can reach in one pass. +6. Write a concise final summary to the output file configured by the CLI. + +Be strict about overclaims. The code is the source of truth, not the report. +"@ + +$codexArgs = @( + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "-C", $projectPath, + "-o", $outputPath +) + +if (-not [string]::IsNullOrWhiteSpace($CodexModel)) { + $codexArgs += @("-m", $CodexModel) +} + +$codexArgs += $reviewPrompt + +$stdoutPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($outputPath), "codex_review_stdout.tmp.txt") +$stderrPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($outputPath), "codex_review_stderr.tmp.txt") + +if (Test-Path -LiteralPath $stdoutPath) { Remove-Item -LiteralPath $stdoutPath -Force } +if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath -Force } + +Push-Location $projectPath +try { + & $codexCommand @codexArgs 1> $stdoutPath 2> $stderrPath + $exitCode = $LASTEXITCODE +} +finally { + Pop-Location +} + +if (Test-Path -LiteralPath $stdoutPath) { + Get-Content -LiteralPath $stdoutPath +} +if (Test-Path -LiteralPath $stderrPath) { + Get-Content -LiteralPath $stderrPath +} + +if ($exitCode -ne 0) { + throw "Codex exited with code $exitCode" +} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json b/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json new file mode 100644 index 0000000..216df61 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json @@ -0,0 +1,401 @@ +{ + "benchmark_info": { + "library_dir": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks", + "top_n": 3, + "roles": [ + "kick", + "snare", + "hat", + "bass_loop", + "vocal_loop", + "top_loop" + ], + "timestamp": "2026-03-20T16:36:16", + "device": "directml" + }, + "references": [ + { + "file_name": "Mr. Pauer, Goyo - Química (Video Oficial).mp3", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\sample\\Mr. Pauer, Goyo - Química (Video Oficial).mp3", + "analysis_time_seconds": 3.09, + "reference_info": { + "tempo": 123.047, + "key": "Cm", + "duration": 145.31, + "rms_mean": 0.17201, + "onset_mean": 1.956218, + "spectral_centroid": 2465.478 + }, + "sections": [ + { + "kind": "verse", + "start": 0.0, + "end": 14.954, + "bars": 8 + }, + { + "kind": "build", + "start": 14.954, + "end": 37.779, + "bars": 12 + }, + { + "kind": "verse", + "start": 37.779, + "end": 46.811, + "bars": 5 + }, + { + "kind": "verse", + "start": 46.811, + "end": 54.822, + "bars": 4 + }, + { + "kind": "drop", + "start": 54.822, + "end": 62.833, + "bars": 4 + }, + { + "kind": "build", + "start": 62.833, + "end": 70.844, + "bars": 4 + }, + { + "kind": "verse", + "start": 70.844, + "end": 92.415, + "bars": 11 + }, + { + "kind": "build", + "start": 92.415, + "end": 101.03, + "bars": 4 + }, + { + "kind": "verse", + "start": 101.03, + "end": 109.041, + "bars": 4 + }, + { + "kind": "build", + "start": 109.041, + "end": 117.098, + "bars": 4 + }, + { + "kind": "outro", + "start": 117.098, + "end": 125.109, + "bars": 4 + }, + { + "kind": "outro", + "start": 125.109, + "end": 133.422, + "bars": 4 + }, + { + "kind": "outro", + "start": 133.422, + "end": 141.433, + "bars": 4 + } + ], + "role_candidates": { + "kick": { + "total_available": 16, + "top_candidates": [ + { + "rank": 1, + "file_name": "BBH - Primer Impacto - Kick 5.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 5.wav", + "score": 0.658173, + "cosine": 0.677478, + "segment_score": 0.807539, + "catalog_score": 0.540981, + "tempo": 117.454, + "key": "Gm", + "duration": 0.5 + }, + { + "rank": 2, + "file_name": "BBH - Primer Impacto - Kick 1.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 1.wav", + "score": 0.650067, + "cosine": 0.633787, + "segment_score": 0.771427, + "catalog_score": 0.540981, + "tempo": 117.454, + "key": "Am", + "duration": 0.5 + }, + { + "rank": 3, + "file_name": "BBH - Primer Impacto - Kick 8.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 8.wav", + "score": 0.642297, + "cosine": 0.689128, + "segment_score": 0.809562, + "catalog_score": 0.5, + "tempo": 258.398, + "key": "Fm", + "duration": 0.484 + } + ] + }, + "snare": { + "total_available": 28, + "top_candidates": [ + { + "rank": 1, + "file_name": "MT Clap & Snare Hit 05.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 05.wav", + "score": 0.642515, + "cosine": 0.742869, + "segment_score": 0.87862, + "catalog_score": 0.529168, + "tempo": 258.398, + "key": "Dm", + "duration": 0.72 + }, + { + "rank": 2, + "file_name": "MT Clap & Snare Hit 15.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 15.wav", + "score": 0.623005, + "cosine": 0.754711, + "segment_score": 0.800798, + "catalog_score": 0.518602, + "tempo": 234.908, + "key": "Dm", + "duration": 0.642 + }, + { + "rank": 3, + "file_name": "BBH - Primer Impacto - Clap 1.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Clap 1.wav", + "score": 0.621014, + "cosine": 0.780775, + "segment_score": 0.805699, + "catalog_score": 0.528549, + "tempo": 117.454, + "key": "A#m", + "duration": 0.545 + } + ] + }, + "hat": { + "total_available": 32, + "top_candidates": [ + { + "rank": 1, + "file_name": "BBH - Primer Impacto - Open Hat 2.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 2.wav", + "score": 0.602448, + "cosine": 0.750913, + "segment_score": 0.789455, + "catalog_score": 0.539635, + "tempo": 258.398, + "key": "Cm", + "duration": 0.625 + }, + { + "rank": 2, + "file_name": "BBH - Primer Impacto - Open Hat 9.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 9.wav", + "score": 0.592739, + "cosine": 0.764186, + "segment_score": 0.682635, + "catalog_score": 0.5, + "tempo": 258.398, + "key": "Gm", + "duration": 0.38 + }, + { + "rank": 3, + "file_name": "MT Hat Hit 04.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Hat Hit 04.wav", + "score": 0.55811, + "cosine": 0.747485, + "segment_score": 0.747228, + "catalog_score": 0.5, + "tempo": 135.999, + "key": "G", + "duration": 0.233 + } + ] + }, + "bass_loop": { + "total_available": 37, + "top_candidates": [ + { + "rank": 1, + "file_name": "Bass_Loop_03_G#m_125.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Bass_Loop_03_G#m_125.wav", + "score": 0.877488, + "cosine": 0.803278, + "segment_score": 0.883592, + "catalog_score": 0.617711, + "tempo": 123.047, + "key": "Cm", + "duration": 7.68 + }, + { + "rank": 2, + "file_name": "BBH - Primer Impacto - Bass Loop 06 Dmin.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Bass Loop 06 Dmin.wav", + "score": 0.82587, + "cosine": 0.698374, + "segment_score": 0.799662, + "catalog_score": 0.890835, + "tempo": 123.047, + "key": "Dm", + "duration": 3.84 + }, + { + "rank": 3, + "file_name": "Bass_Loop_05_Cm_125.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Bass_Loop_05_Cm_125.wav", + "score": 0.818811, + "cosine": 0.695605, + "segment_score": 0.883218, + "catalog_score": 0.617711, + "tempo": 63.024, + "key": "C", + "duration": 7.68 + } + ] + }, + "vocal_loop": { + "total_available": 24, + "top_candidates": [ + { + "rank": 1, + "file_name": "MT Vocal Loop 12 125.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 12 125.wav", + "score": 0.932334, + "cosine": 0.827361, + "segment_score": 0.923902, + "catalog_score": 0.999437, + "tempo": 123.047, + "key": "D#", + "duration": 1.92 + }, + { + "rank": 2, + "file_name": "MT Vocal Loop 11 125.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 11 125.wav", + "score": 0.921701, + "cosine": 0.832834, + "segment_score": 0.920162, + "catalog_score": 0.948909, + "tempo": 123.047, + "key": "D#m", + "duration": 1.92 + }, + { + "rank": 3, + "file_name": "MT Vocal Loop 02 128.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 02 128.wav", + "score": 0.862394, + "cosine": 0.845787, + "segment_score": 0.954025, + "catalog_score": 0.882953, + "tempo": 123.047, + "key": "G#m", + "duration": 3.75 + } + ] + }, + "top_loop": { + "total_available": 144, + "top_candidates": [ + { + "rank": 1, + "file_name": "Top_Loop_11_Any_125.wav", + "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Top_Loop_11_Any_125.wav", + "score": 0.906089, + "cosine": 0.752537, + "segment_score": 0.768995, + "catalog_score": 0.859437, + "tempo": 123.047, + "key": "Cm", + "duration": 7.68 + }, + { + "rank": 2, + "file_name": "drum_loop_21_am_125.wav", + "path": "c:\\programdata\\ableton\\live 12 suite\\resources\\midi remote scripts\\librerias\\all_tracks\\drum_loop_21_am_125.wav", + "score": 0.893566, + "cosine": 0.813975, + "segment_score": 0.954219, + "catalog_score": 0.799711, + "tempo": 123.047, + "key": "A#m", + "duration": 7.68 + }, + { + "rank": 3, + "file_name": "drum_loop_23_am_125.wav", + "path": "c:\\programdata\\ableton\\live 12 suite\\resources\\midi remote scripts\\librerias\\all_tracks\\drum_loop_23_am_125.wav", + "score": 0.887869, + "cosine": 0.822104, + "segment_score": 0.94301, + "catalog_score": 0.799711, + "tempo": 123.047, + "key": "A#m", + "duration": 7.68 + } + ] + } + } + } + ], + "contamination_analysis": { + "cross_role_files": [], + "potential_mismatches": [], + "role_score_stats": { + "kick": { + "min": 0.6423, + "max": 0.6582, + "avg": 0.6502, + "count": 3 + }, + "snare": { + "min": 0.621, + "max": 0.6425, + "avg": 0.6288, + "count": 3 + }, + "hat": { + "min": 0.5581, + "max": 0.6024, + "avg": 0.5844, + "count": 3 + }, + "bass_loop": { + "min": 0.8188, + "max": 0.8775, + "avg": 0.8407, + "count": 3 + }, + "vocal_loop": { + "min": 0.8624, + "max": 0.9323, + "avg": 0.9055, + "count": 3 + }, + "top_loop": { + "min": 0.8879, + "max": 0.9061, + "avg": 0.8958, + "count": 3 + } + } + } +} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 new file mode 100644 index 0000000..c3c0610 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 @@ -0,0 +1,157 @@ +param( + [Parameter(Mandatory = $true)] + [string]$TaskFile, + + [Parameter(Mandatory = $true)] + [string]$ReportFile, + + [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, + [string]$GlmModel = "glm-5", + [string]$GlmBaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), + [string]$GlmAuthToken = $env:ANTHROPIC_AUTH_TOKEN, + [string]$GlmAgentsFile = "", + [string]$CodexModel = "", + [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, + [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, + [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), + [switch]$SkipCodexReview +) + +$ErrorActionPreference = "Stop" + +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Command not found: $Name" + } +} + +function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { + if ([System.IO.Path]::IsPathRooted($TargetPath)) { + return [System.IO.Path]::GetFullPath($TargetPath) + } + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) +} + +function Resolve-TelegramSettings() { + if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { + $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { + $script:TelegramBotToken = $config.bot_token + } + if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { + $script:TelegramChatId = $config.chat_id + } + } +} + +function Send-LoopNotification([string]$Message) { + Resolve-TelegramSettings + if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { + return + } + + $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" + try { + & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath + } + catch { + Write-Warning ("Telegram notification failed: " + $_.Exception.Message) + } +} + +function Resolve-CodexCommand() { + $cmd = Get-Command "codex.cmd" -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $fallback = Get-Command "codex" -ErrorAction SilentlyContinue + if ($fallback) { + return $fallback.Source + } + + throw "Command not found: codex" +} + +$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path +$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path +$reportPath = Resolve-RepoPath $projectPath $ReportFile +$codexCommand = Resolve-CodexCommand + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$runDir = Join-Path $projectPath ("automation\\runs\\loop_" + $timestamp) +New-Item -ItemType Directory -Force -Path $runDir | Out-Null +$codexStdoutPath = Join-Path $runDir "codex_stdout.txt" +$codexMessagePath = Join-Path $runDir "codex_last_message.txt" + +$glmRunner = Join-Path $PSScriptRoot "run_glm_cycle.ps1" +Send-LoopNotification("GLM/Codex loop started: $(Split-Path -Leaf $taskPath)") +& $glmRunner ` + -TaskFile $taskPath ` + -ReportFile $reportPath ` + -ProjectRoot $projectPath ` + -Model $GlmModel ` + -BaseUrl $GlmBaseUrl ` + -AuthToken $GlmAuthToken ` + -AgentsFile $GlmAgentsFile ` + -TelegramBotToken $TelegramBotToken ` + -TelegramChatId $TelegramChatId ` + -TelegramConfigPath $TelegramConfigPath + +if ($SkipCodexReview) { + Send-LoopNotification("GLM/Codex loop finished without Codex review: $(Split-Path -Leaf $taskPath)") + Write-Host "GLM worker finished. Codex review skipped by flag." + return +} + +$reviewPrompt = @" +Read this worker task file: +$taskPath + +Read this GLM report: +$reportPath + +Your job: +1. Inspect the real diff in the repository. +2. Verify whether GLM actually implemented what the report claims. +3. Fix anything incorrect, incomplete, or unsafe. +4. Run the relevant validations mentioned by the task/report. +5. Leave the repository in the best corrected state you can reach in one pass. +6. Write a concise final summary to the output file configured by the CLI. + +Be strict about overclaims. The code is the source of truth, not the report. +"@ + +$codexArgs = @( + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "-C", $projectPath, + "-o", $codexMessagePath +) + +if (-not [string]::IsNullOrWhiteSpace($CodexModel)) { + $codexArgs += @("-m", $CodexModel) +} + +$codexArgs += $reviewPrompt + +Write-Host "" +Write-Host "Running Codex review/correction pass..." +Send-LoopNotification("Codex review started: $(Split-Path -Leaf $taskPath)") + +try { + & $codexCommand @codexArgs 2>&1 | Tee-Object -FilePath $codexStdoutPath +} +catch { + Send-LoopNotification("Codex review failed: $(Split-Path -Leaf $taskPath)`n$($_.Exception.Message)") + throw +} + +Send-LoopNotification("GLM/Codex loop finished: $(Split-Path -Leaf $taskPath)`nReport: $(Split-Path -Leaf $reportPath)`nCodex note: $(Split-Path -Leaf $codexMessagePath)") + +Write-Host "" +Write-Host "Loop finished." +Write-Host "Task: $taskPath" +Write-Host "GLM report: $reportPath" +Write-Host "Codex note: $codexMessagePath" +Write-Host "Codex stdout:$codexStdoutPath" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 new file mode 100644 index 0000000..928f644 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 @@ -0,0 +1,162 @@ +param( + [Parameter(Mandatory = $true)] + [string]$TaskFile, + + [Parameter(Mandatory = $true)] + [string]$ReportFile, + + [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, + [string]$Model = "glm-5", + [string]$BaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), + [string]$AuthToken = $env:ANTHROPIC_AUTH_TOKEN, + [string]$AgentsFile = (Join-Path $PSScriptRoot "glm_agents.team.json"), + [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, + [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, + [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), + [switch]$VerboseLogs +) + +$ErrorActionPreference = "Stop" + +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Command not found: $Name" + } +} + +function Require-File([string]$PathValue, [string]$Label) { + if (-not (Test-Path -LiteralPath $PathValue)) { + throw "$Label not found: $PathValue" + } +} + +function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { + if ([System.IO.Path]::IsPathRooted($TargetPath)) { + return [System.IO.Path]::GetFullPath($TargetPath) + } + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) +} + +function Resolve-TelegramSettings() { + if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { + $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { + $script:TelegramBotToken = $config.bot_token + } + if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { + $script:TelegramChatId = $config.chat_id + } + } +} + +function Send-RunNotification([string]$Message) { + Resolve-TelegramSettings + if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { + return + } + + $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" + try { + & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath + } + catch { + Write-Warning ("Telegram notification failed: " + $_.Exception.Message) + } +} + +Require-Command "claude" +Require-File $TaskFile "Task file" + +if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + throw "ANTHROPIC_BASE_URL is not set. Pass -BaseUrl or export the env var first." +} +if ([string]::IsNullOrWhiteSpace($AuthToken)) { + throw "ANTHROPIC_AUTH_TOKEN is not set. Pass -AuthToken or export the env var first." +} + +$env:ANTHROPIC_BASE_URL = $BaseUrl +$env:ANTHROPIC_AUTH_TOKEN = $AuthToken +$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" +$env:ANTHROPIC_MODEL = $Model +$env:ANTHROPIC_SMALL_FAST_MODEL = $Model +$env:ANTHROPIC_DEFAULT_HAIKU_MODEL = $Model +$env:ANTHROPIC_DEFAULT_SONNET_MODEL = $Model +$env:ANTHROPIC_DEFAULT_OPUS_MODEL = $Model + +$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path +$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path +$reportPath = Resolve-RepoPath $projectPath $ReportFile +$reportDir = Split-Path -Parent $reportPath +New-Item -ItemType Directory -Force -Path $reportDir | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$runDir = Join-Path $projectPath ("automation\\runs\\glm_" + $timestamp) +New-Item -ItemType Directory -Force -Path $runDir | Out-Null +$stdoutPath = Join-Path $runDir "glm_stdout.txt" + +$prompt = @" +You are running as the GLM worker on this Windows repository. + +Repository root: +$projectPath + +Task file to follow exactly: +$taskPath + +You must: +1. Read the task markdown and implement the requested changes in the repository. +2. Run the validations requested by the task. +3. Create or overwrite this report file with a truthful report: +$reportPath +4. Do not overclaim. If something is incomplete, say so explicitly in the report. +5. Keep the diff focused. +6. If custom agents are available, use them aggressively and in parallel where safe: + - planner first + - implementer_core and implementer_aux for disjoint work + - validator before finishing + - retrieval_reviewer or runtime_guard when relevant + - reporter last + +Open and follow the task markdown from disk instead of asking for the task again. +"@ + +$claudeArgs = @( + "-p", + "--dangerously-skip-permissions", + "--effort", "max", + "--model", $Model, + "--add-dir", $projectPath +) + +if (-not [string]::IsNullOrWhiteSpace($AgentsFile)) { + $agentsPath = (Resolve-Path -LiteralPath $AgentsFile).Path + $claudeArgs += @("--agents", (Get-Content -LiteralPath $agentsPath -Raw)) +} + +if ($VerboseLogs) { + $claudeArgs += "--verbose" +} + +Write-Host "Running GLM worker with model $Model..." +Send-RunNotification("GLM worker started: $(Split-Path -Leaf $taskPath)") + +try { + $prompt | & claude @claudeArgs 2>&1 | Tee-Object -FilePath $stdoutPath +} +catch { + Send-RunNotification("GLM worker failed: $(Split-Path -Leaf $taskPath)`n$($_.Exception.Message)") + throw +} + +if (-not (Test-Path -LiteralPath $reportPath)) { + Send-RunNotification("GLM worker failed: missing report for $(Split-Path -Leaf $taskPath)") + throw "GLM finished but did not create the expected report file: $reportPath" +} + +Send-RunNotification("GLM worker finished: $(Split-Path -Leaf $taskPath)`nReport: $(Split-Path -Leaf $reportPath)") + +Write-Host "" +Write-Host "GLM cycle finished." +Write-Host "Task: $taskPath" +Write-Host "Report: $reportPath" +Write-Host "Stdout: $stdoutPath" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 new file mode 100644 index 0000000..0f52379 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 @@ -0,0 +1,141 @@ +param( + [string]$QueueFile = (Join-Path $PSScriptRoot "task_queue.json"), + [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, + [string]$GlmModel = "glm-5", + [string]$GlmBaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), + [string]$GlmAuthToken = $env:ANTHROPIC_AUTH_TOKEN, + [string]$GlmAgentsFile = (Join-Path $PSScriptRoot "glm_agents.team.json"), + [string]$CodexModel = "", + [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, + [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, + [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), + [int]$PollSeconds = 30, + [switch]$Watch, + [switch]$ContinueOnError +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { + if ([System.IO.Path]::IsPathRooted($TargetPath)) { + return [System.IO.Path]::GetFullPath($TargetPath) + } + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) +} + +function Load-Queue([string]$PathValue) { + return Get-Content -LiteralPath $PathValue -Raw | ConvertFrom-Json -Depth 20 +} + +function Save-Queue([string]$PathValue, $QueueObject) { + $QueueObject | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $PathValue -Encoding UTF8 +} + +function Resolve-TelegramSettings() { + if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { + $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { + $script:TelegramBotToken = $config.bot_token + } + if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { + $script:TelegramChatId = $config.chat_id + } + } +} + +function Send-QueueNotification([string]$Message) { + Resolve-TelegramSettings + if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { + return + } + + $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" + try { + & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath + } + catch { + Write-Warning ("Telegram notification failed: " + $_.Exception.Message) + } +} + +function Find-NextTask($QueueObject) { + foreach ($task in $QueueObject.tasks) { + if ($task.enabled -and $task.status -eq "pending") { + return $task + } + } + return $null +} + +$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path +$queuePath = Resolve-RepoPath $projectPath $QueueFile +$loopRunner = Join-Path $PSScriptRoot "run_glm_codex_loop.ps1" +$historyDir = Join-Path $projectPath "automation\\runs\\queue" +New-Item -ItemType Directory -Force -Path $historyDir | Out-Null + +Send-QueueNotification("AbletonMCP_AI queue runner started on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'). Watching=$Watch ContinueOnError=$ContinueOnError") + +do { + $queue = Load-Queue $queuePath + $task = Find-NextTask $queue + + if ($null -eq $task) { + if ($Watch) { + Start-Sleep -Seconds $PollSeconds + continue + } + break + } + + $taskPath = Resolve-RepoPath $projectPath $task.task_file + $reportPath = Resolve-RepoPath $projectPath $task.report_file + + $task.status = "running" + $task.started_at = (Get-Date).ToString("s") + Save-Queue $queuePath $queue + Send-QueueNotification("Queue task started: [$($task.id)] $($task.title)") + + try { + & $loopRunner ` + -TaskFile $taskPath ` + -ReportFile $reportPath ` + -ProjectRoot $projectPath ` + -GlmModel $GlmModel ` + -GlmBaseUrl $GlmBaseUrl ` + -GlmAuthToken $GlmAuthToken ` + -GlmAgentsFile $GlmAgentsFile ` + -CodexModel $CodexModel ` + -TelegramBotToken $TelegramBotToken ` + -TelegramChatId $TelegramChatId ` + -TelegramConfigPath $TelegramConfigPath + + $queue = Load-Queue $queuePath + foreach ($item in $queue.tasks) { + if ($item.id -eq $task.id) { + $item.status = "completed" + $item.completed_at = (Get-Date).ToString("s") + break + } + } + Save-Queue $queuePath $queue + Send-QueueNotification("Queue task completed: [$($task.id)] $($task.title)") + } + catch { + $queue = Load-Queue $queuePath + foreach ($item in $queue.tasks) { + if ($item.id -eq $task.id) { + $item.status = "failed" + $item.failed_at = (Get-Date).ToString("s") + $item.error = $_.Exception.Message + break + } + } + Save-Queue $queuePath $queue + Send-QueueNotification("Queue task failed: [$($task.id)] $($task.title)`n$($_.Exception.Message)") + + if (-not $ContinueOnError) { + throw + } + } +} +while ($true) diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 new file mode 100644 index 0000000..162458b --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [string]$BotToken = $env:TELEGRAM_BOT_TOKEN, + [string]$ChatId = $env:TELEGRAM_CHAT_ID, + [string]$ConfigPath = (Join-Path $PSScriptRoot "telegram.local.json") +) + +$ErrorActionPreference = "Stop" + +if (([string]::IsNullOrWhiteSpace($BotToken) -or [string]::IsNullOrWhiteSpace($ChatId)) -and (Test-Path -LiteralPath $ConfigPath)) { + $config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($BotToken)) { + $BotToken = $config.bot_token + } + if ([string]::IsNullOrWhiteSpace($ChatId)) { + $ChatId = $config.chat_id + } +} + +if ([string]::IsNullOrWhiteSpace($BotToken) -or [string]::IsNullOrWhiteSpace($ChatId)) { + exit 0 +} + +$uri = "https://api.telegram.org/bot$BotToken/sendMessage" +$body = @{ + chat_id = $ChatId + text = $Message + disable_web_page_preview = $true +} + +Invoke-RestMethod -Uri $uri -Method Post -Body $body | Out-Null diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service new file mode 100644 index 0000000..9629ddc --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service @@ -0,0 +1,18 @@ +[Unit] +Description=AbletonMCP_AI autonomous GLM/Codex queue +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ren +WorkingDirectory=/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI +Environment=LOCAL_ENV_FILE=/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl.local.env +ExecStart=/bin/bash /mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl/run_task_queue.sh +Restart=always +RestartSec=15 +StandardOutput=append:/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl_runtime/logs/service.log +StandardError=append:/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl_runtime/logs/service.log + +[Install] +WantedBy=multi-user.target diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh new file mode 100644 index 0000000..30d5b9c --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RUNTIME_DIR="$PROJECT_ROOT/automation/wsl_runtime" +CODEX_HOME_DIR="$RUNTIME_DIR/codex_home" +WINDOWS_CODEX_HOME="/mnt/c/Users/ren/.codex" +ENV_FILE="$PROJECT_ROOT/automation/wsl.local.env" +OPENAI_API_KEY_VALUE="" + +mkdir -p "$CODEX_HOME_DIR" "$RUNTIME_DIR/logs" + +if [[ -f "$WINDOWS_CODEX_HOME/auth.json" && ! -f "$CODEX_HOME_DIR/auth.json" ]]; then + cp "$WINDOWS_CODEX_HOME/auth.json" "$CODEX_HOME_DIR/auth.json" +fi + +if [[ -f "$CODEX_HOME_DIR/auth.json" ]]; then + OPENAI_API_KEY_VALUE="$(jq -r '.OPENAI_API_KEY // empty' "$CODEX_HOME_DIR/auth.json" 2>/dev/null || true)" +fi + +cat > "$CODEX_HOME_DIR/config.toml" <<'EOF' +model = "gpt-5.4" + +[sandbox_workspace_write] +network_access = true +EOF + +cat > "$ENV_FILE" <> "$ENV_FILE" +fi + +chmod 600 "$ENV_FILE" "$CODEX_HOME_DIR/auth.json" 2>/dev/null || true +chmod +x "$SCRIPT_DIR/"*.sh + +echo "WSL runtime bootstrapped" +echo "Runtime dir: $RUNTIME_DIR" +echo "Env file: $ENV_FILE" +echo "Codex home: $CODEX_HOME_DIR" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml new file mode 100644 index 0000000..79eca51 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml @@ -0,0 +1,163 @@ +services: + postgres: + image: postgres:16-alpine + container_name: abletonmcp-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + POSTGRES_DB: ${POSTGRES_BOOTSTRAP_DB:-postgres} + PGDATA: /var/lib/postgresql/data/pgdata + GITEA_DB_NAME: ${GITEA_DB_NAME:-gitea} + N8N_DB_NAME: ${N8N_DB_NAME:-n8n} + volumes: + - postgres-data:/var/lib/postgresql/data + - ./initdb:/docker-entrypoint-initdb.d:ro + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_BOOTSTRAP_DB:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + networks: + - internal + + redis: + image: redis:7-alpine + container_name: abletonmcp-redis + restart: unless-stopped + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD:-changeme} + - --appendonly + - "yes" + - --save + - "60" + - "1000" + volumes: + - redis-data:/data + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-changeme} ping | grep -q PONG"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - internal + + gitea: + image: gitea/gitea:1.21-rootless + container_name: abletonmcp-gitea + restart: unless-stopped + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: postgres:5432 + GITEA__database__NAME: ${GITEA_DB_NAME:-gitea} + GITEA__database__USER: ${POSTGRES_USER:-postgres} + GITEA__database__PASSWD: ${POSTGRES_PASSWORD:-changeme} + GITEA__server__DOMAIN: ${GITEA_DOMAIN:-localhost} + GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000} + GITEA__server__HTTP_PORT: 3000 + GITEA__server__SSH_DOMAIN: ${GITEA_SSH_DOMAIN:-localhost} + GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-222} + GITEA__server__START_SSH_SERVER: "true" + GITEA__server__SSH_LISTEN_PORT: 222 + GITEA__security__INSTALL_LOCK: ${GITEA_SECURITY_INSTALL_LOCK:-true} + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__server__OFFLINE_MODE: ${GITEA_OFFLINE_MODE:-true} + volumes: + - gitea-data:/var/lib/gitea + - gitea-config:/etc/gitea + - gitea-logs:/var/log/gitea + ports: + - "${GITEA_HTTP_PORT:-3000}:3000" + - "${GITEA_SSH_PORT:-222}:222" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/healthz || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + networks: + - internal + + n8n: + image: n8nio/n8n:latest + container_name: abletonmcp-n8n + restart: unless-stopped + environment: + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_PORT: 5432 + DB_POSTGRESDB_DATABASE: ${N8N_DB_NAME:-n8n} + DB_POSTGRESDB_USER: ${POSTGRES_USER:-postgres} + DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + N8N_PORT: 5678 + N8N_PROTOCOL: http + N8N_HOST: ${N8N_HOST:-localhost} + N8N_PATH: ${N8N_PATH:-/} + N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:-changeme-change-this} + N8N_LOG_LEVEL: ${N8N_LOG_LEVEL:-info} + N8N_EXECUTIONS_MODE: ${N8N_EXECUTIONS_MODE:-regular} + N8N_BASIC_AUTH_ACTIVE: ${N8N_BASIC_AUTH_ACTIVE:-true} + N8N_BASIC_AUTH_USER: ${N8N_BASIC_AUTH_USER:-admin} + N8N_BASIC_AUTH_PASSWORD: ${N8N_BASIC_AUTH_PASSWORD:-changeme} + N8N_COOKIE_POLICY: ${N8N_COOKIE_POLICY:-lax} + N8N_HOST_ALLOW_LIST: ${N8N_HOST_ALLOW_LIST:-localhost,127.0.0.1} + N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://localhost:5678/} + N8N_EDITOR_BASE_URL: ${N8N_EDITOR_BASE_URL:-http://localhost:5678} + GENERIC_TIMEZONE: ${TZ:-UTC} + TZ: ${TZ:-UTC} + N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED:-false} + N8N_VERSION_NOTIFICATIONS_ENABLED: ${N8N_VERSION_NOTIFICATIONS_ENABLED:-false} + volumes: + - n8n-data:/home/node/.n8n + - n8n-logs:/home/node/.npm/_logs + - ${PROJECT_PATH:-/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI}:/project:rw + - ${PROJECT_PATH:-/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI}/automation/workflows:/workflows:ro + ports: + - "${N8N_PORT:-5678}:5678" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - internal + +networks: + internal: + name: abletonmcp-network + driver: bridge + +volumes: + postgres-data: + name: abletonmcp-postgres-data + gitea-data: + name: abletonmcp-gitea-data + gitea-config: + name: abletonmcp-gitea-config + gitea-logs: + name: abletonmcp-gitea-logs + redis-data: + name: abletonmcp-redis-data + n8n-data: + name: abletonmcp-n8n-data + n8n-logs: + name: abletonmcp-n8n-logs diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh new file mode 100644 index 0000000..67f014b --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +create_db() { + db_name="$1" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_BOOTSTRAP_DB:-postgres}" <<-EOSQL + SELECT 'CREATE DATABASE "${db_name}"' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${db_name}')\gexec +EOSQL +} + +if [ -n "${GITEA_DB_NAME:-}" ]; then + create_db "$GITEA_DB_NAME" +fi + +if [ -n "${N8N_DB_NAME:-}" ]; then + create_db "$N8N_DB_NAME" +fi diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh new file mode 100644 index 0000000..2630724 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_SRC="$SCRIPT_DIR/ableton-glm-loop.service" +SERVICE_DST="/etc/systemd/system/ableton-glm-loop.service" + +sudo cp "$SERVICE_SRC" "$SERVICE_DST" +sudo systemctl daemon-reload +sudo systemctl enable ableton-glm-loop.service +sudo systemctl restart ableton-glm-loop.service +sudo systemctl status --no-pager ableton-glm-loop.service || true diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh new file mode 100644 index 0000000..05e7c2f --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" + +if [[ -f "$LOCAL_ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$LOCAL_ENV_FILE" +fi + +TASK_FILE="${1:?task file is required}" +REPORT_FILE="${2:?report file is required}" +GLM_MODEL="${GLM_MODEL:-glm-5}" +CODEX_MODEL="${CODEX_MODEL:-gpt-5.4}" +SKIP_CODEX_REVIEW="${SKIP_CODEX_REVIEW:-0}" +CODEX_HOME="${CODEX_HOME:-$PROJECT_ROOT/automation/wsl_runtime/codex_home}" +export CODEX_HOME +if [[ -n "${OPENAI_API_KEY:-}" ]]; then + export OPENAI_API_KEY +fi + +RUN_DIR="$PROJECT_ROOT/automation/runs/loop_$(date +%Y%m%d_%H%M%S)" +CODEX_STDOUT_PATH="$RUN_DIR/codex_stdout.txt" +CODEX_MESSAGE_PATH="$RUN_DIR/codex_last_message.txt" +mkdir -p "$RUN_DIR" + +notify() { + "$SCRIPT_DIR/send_telegram.sh" "$1" || true +} + +notify "GLM/Codex loop started: $(basename "$TASK_FILE")" +"$SCRIPT_DIR/run_glm_cycle.sh" "$TASK_FILE" "$REPORT_FILE" + +if [[ "$SKIP_CODEX_REVIEW" == "1" ]]; then + notify "GLM/Codex loop finished without Codex review: $(basename "$TASK_FILE")" + exit 0 +fi + +notify "Codex review started: $(basename "$TASK_FILE")" + +WIN_TASK_FILE="$(wslpath -w "$TASK_FILE")" +WIN_REPORT_FILE="$(wslpath -w "$REPORT_FILE")" +WIN_PROJECT_ROOT="$(wslpath -w "$PROJECT_ROOT")" +WIN_CODEX_MESSAGE_PATH="$(wslpath -w "$CODEX_MESSAGE_PATH")" +WIN_REVIEW_SCRIPT="$(wslpath -w "$PROJECT_ROOT/automation/invoke_codex_review.ps1")" + +if ! /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$WIN_REVIEW_SCRIPT" -TaskFile "$WIN_TASK_FILE" -ReportFile "$WIN_REPORT_FILE" -ProjectRoot "$WIN_PROJECT_ROOT" -OutputFile "$WIN_CODEX_MESSAGE_PATH" -CodexModel "$CODEX_MODEL" 2>&1 | tee "$CODEX_STDOUT_PATH"; then + notify "Codex review failed: $(basename "$TASK_FILE")" + exit 1 +fi + +notify "GLM/Codex loop finished: $(basename "$TASK_FILE")" +echo "Loop finished" +echo "Task: $TASK_FILE" +echo "GLM report: $REPORT_FILE" +echo "Codex note: $CODEX_MESSAGE_PATH" +echo "Codex stdout:$CODEX_STDOUT_PATH" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh new file mode 100644 index 0000000..d5020a4 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" + +if [[ -f "$LOCAL_ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$LOCAL_ENV_FILE" +fi + +TASK_FILE="${1:?task file is required}" +REPORT_FILE="${2:?report file is required}" +GLM_MODEL="${GLM_MODEL:-glm-5}" +GLM_AGENTS_FILE="${GLM_AGENTS_FILE:-$PROJECT_ROOT/automation/glm_agents.team.json}" + +export ANTHROPIC_BASE_URL="${ANTHROPIC_BASE_URL:-https://coding-intl.dashscope.aliyuncs.com/apps/anthropic}" +export ANTHROPIC_AUTH_TOKEN="${ANTHROPIC_AUTH_TOKEN:?ANTHROPIC_AUTH_TOKEN is required}" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" +export ANTHROPIC_MODEL="$GLM_MODEL" +export ANTHROPIC_SMALL_FAST_MODEL="$GLM_MODEL" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="$GLM_MODEL" +export ANTHROPIC_DEFAULT_SONNET_MODEL="$GLM_MODEL" +export ANTHROPIC_DEFAULT_OPUS_MODEL="$GLM_MODEL" + +RUN_DIR="$PROJECT_ROOT/automation/runs/glm_$(date +%Y%m%d_%H%M%S)" +STDOUT_PATH="$RUN_DIR/glm_stdout.txt" +mkdir -p "$RUN_DIR" "$(dirname "$REPORT_FILE")" + +notify() { + "$SCRIPT_DIR/send_telegram.sh" "$1" || true +} + +PROMPT=$(cat <&1 | tee "$STDOUT_PATH"; then + notify "GLM worker failed: $(basename "$TASK_FILE")" + exit 1 +fi + +if [[ ! -f "$REPORT_FILE" ]]; then + notify "GLM worker failed: missing report for $(basename "$TASK_FILE")" + echo "missing report: $REPORT_FILE" >&2 + exit 1 +fi + +notify "GLM worker finished: $(basename "$TASK_FILE")" +echo "GLM cycle finished" +echo "Task: $TASK_FILE" +echo "Report: $REPORT_FILE" +echo "Stdout: $STDOUT_PATH" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh new file mode 100644 index 0000000..aec3377 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +QUEUE_FILE="${QUEUE_FILE:-$PROJECT_ROOT/automation/task_queue.json}" +LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" +POLL_SECONDS="${POLL_SECONDS:-30}" +WATCH="${WATCH:-1}" +CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" + +if [[ -f "$LOCAL_ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$LOCAL_ENV_FILE" +fi + +notify() { + "$SCRIPT_DIR/send_telegram.sh" "$1" || true +} + +queue_has_pending() { + jq -e '.tasks[] | select(.enabled == true and .status == "pending")' "$QUEUE_FILE" >/dev/null +} + +read_next_task() { + jq -r '.tasks[] | select(.enabled == true and .status == "pending") | @base64' "$QUEUE_FILE" | head -n 1 +} + +update_task_status() { + local task_id="$1" + local status="$2" + local field="$3" + local value="$4" + local tmp + local queue_dir + queue_dir="$(dirname "$QUEUE_FILE")" + tmp="$(mktemp "$queue_dir/.task_queue.tmp.XXXXXX")" + jq --arg id "$task_id" --arg status "$status" --arg field "$field" --arg value "$value" ' + .tasks |= map( + if .id == $id then + .status = $status | .[$field] = $value + else + . + end + )' "$QUEUE_FILE" > "$tmp" + mv "$tmp" "$QUEUE_FILE" +} + +set_task_error() { + local task_id="$1" + local message="$2" + local tmp + local queue_dir + queue_dir="$(dirname "$QUEUE_FILE")" + tmp="$(mktemp "$queue_dir/.task_queue.tmp.XXXXXX")" + jq --arg id "$task_id" --arg msg "$message" ' + .tasks |= map( + if .id == $id then + .error = $msg + else + . + end + )' "$QUEUE_FILE" > "$tmp" + mv "$tmp" "$QUEUE_FILE" +} + +notify "AbletonMCP_AI queue runner started on $(date '+%Y-%m-%d %H:%M:%S')" + +while true; do + if ! queue_has_pending; then + if [[ "$WATCH" == "1" ]]; then + sleep "$POLL_SECONDS" + continue + fi + break + fi + + task_b64="$(read_next_task)" + if [[ -z "$task_b64" ]]; then + sleep "$POLL_SECONDS" + continue + fi + + task_json="$(printf '%s' "$task_b64" | base64 -d)" + task_id="$(printf '%s' "$task_json" | jq -r '.id')" + task_title="$(printf '%s' "$task_json" | jq -r '.title')" + task_file_rel="$(printf '%s' "$task_json" | jq -r '.task_file')" + report_file_rel="$(printf '%s' "$task_json" | jq -r '.report_file')" + task_file="$PROJECT_ROOT/${task_file_rel//\\//}" + report_file="$PROJECT_ROOT/${report_file_rel//\\//}" + + update_task_status "$task_id" "running" "started_at" "$(date -Iseconds)" + notify "Queue task started: [$task_id] $task_title" + + if "$SCRIPT_DIR/run_glm_codex_loop.sh" "$task_file" "$report_file"; then + update_task_status "$task_id" "completed" "completed_at" "$(date -Iseconds)" + notify "Queue task completed: [$task_id] $task_title" + else + update_task_status "$task_id" "failed" "failed_at" "$(date -Iseconds)" + set_task_error "$task_id" "task runner failed" + notify "Queue task failed: [$task_id] $task_title" + if [[ "$CONTINUE_ON_ERROR" != "1" ]]; then + exit 1 + fi + fi +done diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh new file mode 100644 index 0000000..b7b84bb --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# +# install.sh - Install Docker, Docker Compose, and local Python runtime on Ubuntu 24.04 WSL2 +# Idempotent: safe to run multiple times +# + +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$AUTOMATION_DIR/.." && pwd)" +RUNTIME_DIR="$AUTOMATION_DIR/wsl_runtime" +VENV_DIR="$RUNTIME_DIR/venv" + +check_sudo() { + if [[ $EUID -eq 0 ]]; then + log_error "This script should not be run as root. It will use sudo when needed." + exit 1 + fi +} + +detect_ubuntu() { + if [[ ! -f /etc/os-release ]]; then + log_error "Cannot detect OS version. /etc/os-release not found." + exit 1 + fi + + # shellcheck disable=SC1091 + source /etc/os-release + if [[ "${ID:-}" != "ubuntu" ]]; then + log_warn "This script is designed for Ubuntu. Detected: ${ID:-unknown}" + fi + + log_info "Detected Ubuntu ${VERSION_ID:-unknown}" +} + +check_wsl2() { + if [[ ! -f /proc/version ]]; then + log_warn "Cannot verify WSL environment" + return + fi + + if grep -qi microsoft /proc/version; then + log_info "Running in WSL environment" + else + log_warn "Not running in WSL. This script is designed for WSL2." + fi +} + +install_docker() { + log_info "Checking Docker installation..." + + if command -v docker >/dev/null 2>&1; then + log_info "Docker already installed: $(docker --version)" + else + log_info "Installing Docker..." + sudo apt-get update -q + sudo apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + software-properties-common + + sudo install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + sudo chmod a+r /etc/apt/keyrings/docker.gpg + fi + + local codename + codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + sudo tee /etc/apt/sources.list.d/docker.list >/dev/null </dev/null 2>&1; then + log_info "Python already installed: $(python3 --version)" + else + sudo apt-get update -q + sudo apt-get install -y python3 python3-pip python3-venv python3-full + fi +} + +install_utilities() { + log_info "Installing system utilities..." + + sudo apt-get update -q + sudo apt-get install -y \ + jq \ + git \ + curl \ + wget \ + rsync \ + net-tools \ + dnsutils \ + htop \ + ncdu \ + tree \ + unzip \ + zip \ + httpie \ + python3-rich \ + pipx +} + +configure_docker_wsl2() { + log_info "Configuring Docker for WSL..." + + local docker_config_dir="/etc/docker" + local docker_config_file="$docker_config_dir/daemon.json" + + if [[ ! -f "$docker_config_file" ]]; then + sudo mkdir -p "$docker_config_dir" + sudo tee "$docker_config_file" >/dev/null <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "features": { + "containerd-snapshotter": true + }, + "iptables": false +} +EOF + sudo systemctl restart docker + fi + + local bashrc_file="$HOME/.bashrc" + if ! grep -q 'WSL Docker helpers' "$bashrc_file" 2>/dev/null; then + cat >> "$bashrc_file" <<'EOF' + +# WSL Docker helpers +export DOCKER_HOST=unix:///var/run/docker.sock +EOF + fi +} + +handle_windows_paths() { + log_info "Ensuring project symlink exists..." + if [[ ! -L "$HOME/ableton-mcp-ai" ]]; then + ln -sfn "$PROJECT_ROOT" "$HOME/ableton-mcp-ai" + fi +} + +install_python_dependencies() { + log_info "Preparing local virtual environment..." + mkdir -p "$RUNTIME_DIR" + + if [[ ! -d "$VENV_DIR" ]]; then + python3 -m venv "$VENV_DIR" + fi + + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + python -m pip install --upgrade pip + + local found_req=false + local requirements_files=( + "$PROJECT_ROOT/MCP_Server/requirements.txt" + "$PROJECT_ROOT/requirements.txt" + ) + + for req_file in "${requirements_files[@]}"; do + if [[ -f "$req_file" ]]; then + log_info "Installing dependencies from: $req_file" + python -m pip install -r "$req_file" + found_req=true + fi + done + + if [[ "$found_req" == "false" ]]; then + log_warn "No requirements.txt files found" + fi + + deactivate +} + +verify_installation() { + log_info "Verifying installation..." + + local all_good=true + + if command -v docker >/dev/null 2>&1; then + log_info "OK Docker: $(docker --version)" + else + log_error "FAIL Docker not found" + all_good=false + fi + + if docker compose version >/dev/null 2>&1; then + log_info "OK Docker Compose: $(docker compose version)" + else + log_error "FAIL Docker Compose not found" + all_good=false + fi + + if command -v python3 >/dev/null 2>&1; then + log_info "OK Python: $(python3 --version)" + else + log_error "FAIL Python3 not found" + all_good=false + fi + + if [[ -x "$VENV_DIR/bin/python" ]]; then + log_info "OK Venv: $VENV_DIR" + else + log_error "FAIL Venv not found at $VENV_DIR" + all_good=false + fi + + if command -v jq >/dev/null 2>&1; then + log_info "OK jq installed" + else + log_error "FAIL jq not found" + all_good=false + fi + + if [[ "$all_good" == "true" ]]; then + log_info "All dependencies installed successfully" + return 0 + fi + + log_error "Some dependencies failed to install" + return 1 +} + +main() { + log_info "Starting AbletonMCP-AI WSL installation..." + echo + + check_sudo + detect_ubuntu + check_wsl2 + echo + + install_docker + install_python + install_utilities + configure_docker_wsl2 + handle_windows_paths + install_python_dependencies + echo + + verify_installation + echo + + log_info "Installation complete" + log_info "Next step: run ./setup.sh and then ./start.sh" +} + +main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh new file mode 100644 index 0000000..c6c12d5 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SYSTEMD_DIR="$WSL_DIR/systemd" + +if [[ $EUID -ne 0 ]]; then + echo "Run with sudo" + exit 1 +fi + +for service_file in "$SYSTEMD_DIR"/*.service; do + cp "$service_file" /etc/systemd/system/"$(basename "$service_file")" +done + +systemctl daemon-reload +systemctl enable abletonmcp-stack.service abletonmcp-queue-runner.service +echo "Installed systemd units" +echo "Enabled by default: abletonmcp-stack.service, abletonmcp-queue-runner.service" +echo "Optional unit left disabled: abletonmcp-glm-runner.service" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh new file mode 100644 index 0000000..10e3c30 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" +DOCKER_ENV_FILE="$WSL_DIR/.env" +COMPOSE_FILE="$WSL_DIR/docker-compose.yml" +LOGS_DIR="$AUTOMATION_DIR/wsl_runtime/logs" + +follow="${1:-all}" + +compose_cmd() { + docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +case "$follow" in + docker) + compose_cmd logs -f + ;; + queue) + tail -f "$LOGS_DIR/queue-runner.log" + ;; + all) + compose_cmd logs -f & + docker_pid=$! + if [[ -f "$LOGS_DIR/queue-runner.log" ]]; then + tail -f "$LOGS_DIR/queue-runner.log" & + tail_pid=$! + wait "$docker_pid" "$tail_pid" + else + wait "$docker_pid" + fi + ;; + *) + echo "Usage: $0 [all|docker|queue]" + exit 1 + ;; +esac diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh new file mode 100644 index 0000000..08c9870 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$SCRIPT_DIR/stop.sh" +sleep 2 +"$SCRIPT_DIR/start.sh" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh new file mode 100644 index 0000000..5b14825 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $*"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$AUTOMATION_DIR/.." && pwd)" +RUNTIME_DIR="$AUTOMATION_DIR/wsl_runtime" +DOCKER_ENV_FILE="$WSL_DIR/.env" +RUNNER_ENV_FILE="$AUTOMATION_DIR/wsl.local.env" +PROJECT_LINK="$HOME/ableton-mcp-ai" + +generate_secret() { + openssl rand -hex "${1:-16}" 2>/dev/null || python3 - <<'PY' +import secrets +print(secrets.token_hex(16)) +PY +} + +ensure_dirs() { + log_step "Creating runtime directories" + mkdir -p \ + "$RUNTIME_DIR/logs" \ + "$RUNTIME_DIR/pids" \ + "$RUNTIME_DIR/data" \ + "$AUTOMATION_DIR/reports" \ + "$AUTOMATION_DIR/runs" \ + "$AUTOMATION_DIR/tasks" \ + "$AUTOMATION_DIR/workflows" \ + "$WSL_DIR/initdb" +} + +ensure_symlink() { + if [[ ! -L "$PROJECT_LINK" ]]; then + ln -sfn "$PROJECT_ROOT" "$PROJECT_LINK" + fi + log_info "Project link: $PROJECT_LINK" +} + +write_docker_env() { + if [[ -f "$DOCKER_ENV_FILE" ]]; then + log_info "Docker env already exists: $DOCKER_ENV_FILE" + return + fi + + log_step "Generating docker env" + cat > "$DOCKER_ENV_FILE" < "$RUNNER_ENV_FILE" </dev/null || { log_error "Docker is not installed"; exit 1; } + docker compose version >/dev/null || { log_error "Docker Compose plugin is not available"; exit 1; } + docker info >/dev/null || { log_error "Docker daemon is not running"; exit 1; } + [[ -f "$DOCKER_ENV_FILE" ]] || { log_error "Missing docker env: $DOCKER_ENV_FILE"; exit 1; } + [[ -f "$COMPOSE_FILE" ]] || { log_error "Missing compose file: $COMPOSE_FILE"; exit 1; } +} + +wait_for_postgres() { + log_info "Waiting for PostgreSQL" + for _ in $(seq 1 60); do + if compose_cmd exec -T postgres pg_isready -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + log_error "PostgreSQL did not become ready in time" + exit 1 +} + +wait_for_service_http() { + local service="$1" + local url="$2" + log_info "Waiting for $service" + for _ in $(seq 1 60); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + log_warn "$service is not healthy yet: $url" + return 1 +} + +ensure_database() { + local db_name="$1" + if compose_cmd exec -T postgres psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" -tAc "SELECT 1 FROM pg_database WHERE datname='${db_name}'" | grep -q 1; then + return 0 + fi + compose_cmd exec -T postgres psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" -c "CREATE DATABASE \"${db_name}\"" +} + +ensure_gitea_admin() { + local user="${GITEA_ADMIN_USER:-giteaadmin}" + local password="${GITEA_ADMIN_PASSWORD:-changeme}" + local email="${GITEA_ADMIN_EMAIL:-admin@localhost}" + if compose_cmd exec -T gitea sh -c "HOME=/tmp /usr/local/bin/gitea admin user list 2>/dev/null | awk 'NR > 1 && \$2 == \"${user}\" { found=1 } END { exit found ? 0 : 1 }'"; then + return 0 + fi + compose_cmd exec -T gitea sh -c "HOME=/tmp /usr/local/bin/gitea admin user create --admin --username '${user}' --password '${password}' --email '${email}' --must-change-password=false" >/dev/null 2>&1 || log_warn "Could not auto-create Gitea admin user; complete first-run in UI if needed" +} + +start_docker_stack() { + log_step "Starting Docker services" + compose_cmd up -d postgres redis + wait_for_postgres + ensure_database "${GITEA_DB_NAME:-gitea}" + ensure_database "${N8N_DB_NAME:-n8n}" + compose_cmd up -d gitea n8n + wait_for_service_http "Gitea" "http://localhost:${GITEA_HTTP_PORT:-3000}/api/healthz" || true + wait_for_service_http "n8n" "http://localhost:${N8N_PORT:-5678}/healthz" || true + ensure_gitea_admin +} + +start_queue_runner() { + if [[ "$START_QUEUE_RUNNER" != "1" ]]; then + log_info "Queue runner startup skipped by START_QUEUE_RUNNER=$START_QUEUE_RUNNER" + return + fi + + if command -v systemctl >/dev/null 2>&1 && systemctl is-active abletonmcp-queue-runner.service >/dev/null 2>&1; then + log_info "Queue runner already managed by systemd" + return + fi + + local pid_file="$PID_DIR/queue-runner.pid" + if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + log_info "Queue runner already running" + return + fi + + log_step "Starting autonomous queue runner" + nohup bash "$WSL_DIR/run_task_queue.sh" > "$LOGS_DIR/queue-runner.log" 2>&1 & + echo $! > "$pid_file" + log_info "Queue runner PID: $(cat "$pid_file")" +} + +main() { + check_prerequisites + start_docker_stack + start_queue_runner + echo + log_info "Stack started" + echo " Gitea: http://localhost:${GITEA_HTTP_PORT:-3000}" + echo " n8n: http://localhost:${N8N_PORT:-5678}" +} + +main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh new file mode 100644 index 0000000..691ea89 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly RED='\033[0;31m' +readonly NC='\033[0m' + +ok() { echo -e "${GREEN}OK${NC} $*"; } +warn() { echo -e "${YELLOW}WARN${NC} $*"; } +fail() { echo -e "${RED}FAIL${NC} $*"; } +step() { echo -e "${BLUE}$*${NC}"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" +DOCKER_ENV_FILE="$WSL_DIR/.env" +COMPOSE_FILE="$WSL_DIR/docker-compose.yml" +PID_DIR="$AUTOMATION_DIR/wsl_runtime/pids" +LOGS_DIR="$AUTOMATION_DIR/wsl_runtime/logs" + +compose_cmd() { + docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +step "Docker" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + ok "docker daemon running" +else + fail "docker daemon unavailable" +fi +echo + +step "Compose services" +if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then + compose_cmd ps || true +else + warn "compose file or docker missing" +fi +echo + +step "Queue runner" +if [[ -f "$PID_DIR/queue-runner.pid" ]] && kill -0 "$(cat "$PID_DIR/queue-runner.pid")" 2>/dev/null; then + ok "queue runner PID $(cat "$PID_DIR/queue-runner.pid")" +elif command -v systemctl >/dev/null 2>&1 && systemctl is-active abletonmcp-queue-runner.service >/dev/null 2>&1; then + ok "queue runner managed by systemd" +else + warn "queue runner not running" +fi +echo + +step "Logs" +if [[ -d "$LOGS_DIR" ]]; then + ls -1 "$LOGS_DIR" | sed 's/^/ - /' +else + warn "no logs directory" +fi diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh new file mode 100644 index 0000000..c85919a --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $*"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" +DOCKER_ENV_FILE="$WSL_DIR/.env" +COMPOSE_FILE="$WSL_DIR/docker-compose.yml" +PID_DIR="$AUTOMATION_DIR/wsl_runtime/pids" + +compose_cmd() { + docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +stop_runner() { + local pid_file="$1" + if [[ ! -f "$pid_file" ]]; then + return + fi + local pid + pid="$(cat "$pid_file")" + if kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + sleep 2 + kill -KILL "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" +} + +main() { + log_step "Stopping queue runner" + stop_runner "$PID_DIR/queue-runner.pid" + echo + log_step "Stopping Docker services" + if command -v docker >/dev/null 2>&1; then + compose_cmd down "$@" || true + else + log_warn "Docker not installed" + fi + log_info "Stack stopped" +} + +main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh new file mode 100644 index 0000000..7f55670 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" + +if [[ -f "$LOCAL_ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$LOCAL_ENV_FILE" +fi + +MESSAGE="${1:-}" +if [[ -z "$MESSAGE" ]]; then + exit 0 +fi + +BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" +CHAT_ID="${TELEGRAM_CHAT_ID:-}" + +if [[ -z "$BOT_TOKEN" || -z "$CHAT_ID" ]]; then + exit 0 +fi + +curl -fsS -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" \ + --data "disable_web_page_preview=true" >/dev/null diff --git a/AbletonMCP_AI_BAK_20260328_200801/load_samples.py b/AbletonMCP_AI_BAK_20260328_200801/load_samples.py new file mode 100644 index 0000000..6e58efc --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/load_samples.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +""" +Script para cargar samples en Ableton MCP AI +Este script guía al usuario para cargar samples manualmente o usa el browser +""" +import os + +# Configuración de samples +SAMPLES_CONFIG = { + "kick": r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav", + "clap": r"C:\Users\ren\embeddings\all_tracks\MT Clap & Snare Hit 05.wav", + "hat": r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Closed Hat 3.wav", + "bass": r"C:\Users\ren\embeddings\all_tracks\MT_Bass Loop 04 F 125.wav", +} + +def generate_instrument_setup_guide(): + """Genera instrucciones detalladas para cargar samples""" + + guide = """ +╔══════════════════════════════════════════════════════════════════╗ +║ CONFIGURACIÓN DE INSTRUMENTOS - HOUSE 90s ║ +╚══════════════════════════════════════════════════════════════════╝ + +Para que suene tu track, necesitas cargar instrumentos en cada track MIDI. + +🥁 TRACK 0 - KICK (Rojo): + 1. Arrastra "Drum Rack" del browser al track + 2. Arrastra tu sample de kick al pad C1 (nota 36) + 3. Ajusta volumen a -3dB + +👏 TRACK 1 - CLAP (Naranja): + 1. Mismo Drum Rack o uno nuevo + 2. Arrastra sample de clap/snare al pad D2 (nota 50) + 3. Volumen a -6dB + +🎩 TRACK 2 - HIHAT (Amarillo): + 1. Drum Rack + 2. Sample de closed hat al pad F#1 (nota 42) + 3. Volumen a -12dB + +🎸 TRACK 3 - BASS (Azul): + Opción A (Sampler): + 1. Arrastra "Simpler" al track + 2. Arrastra loop de bass (MT_Bass Loop 04 F 125.wav) + 3. Ajusta para que C3 dispare el sample + + Opción B (Synth): + 1. Carga "Operator" + 2. Preset "Sub Bass" o "Funky Bass" + 3. Ajusta envolvente: Attack 5ms, Decay 200ms, Sustain 80% + +🎹 TRACK 4 - CHORDS (Purpura): + 1. Carga "Wavetable" o "Analog" + 2. Preset "House Chords", "Chord Stab" o "Vintage Keys" + 3. Añade reverb (Return A) al 20% + +═══════════════════════════════════════════════════════════════════ + +📁 SAMPLES RECOMENDADOS DE TU LIBRERÍA: + +Kick: BBH - Primer Impacto - Kick 1.wav +Clap: MT Clap & Snare Hit 05.wav +Hat: BBH - Primer Impacto - Closed Hat 3.wav +Bass: MT_Bass Loop 04 F 125.wav + +═══════════════════════════════════════════════════════════════════ + +⚡ ATAJO RÁPIDO: +Si tienes Drum Rack presets guardados: +1. Busca en el browser: "Drums > Drum Rack" +2. Arrastra a cada track de drums +3. Los clips MIDI ya están programados y sonarán automáticamente + +═══════════════════════════════════════════════════════════════════ +""" + return guide + + +def verify_samples(): + """Verifica qué samples existen""" + samples_dir = r"C:\Users\ren\embeddings\all_tracks" + + print("\n📂 Verificando samples en librería...") + print(f"Directorio: {samples_dir}") + print("-" * 50) + + if not os.path.exists(samples_dir): + print("❌ Directorio no encontrado!") + return False + + # Buscar archivos comunes + found = [] + for f in os.listdir(samples_dir)[:20]: # Primeros 20 + if f.endswith('.wav'): + found.append(f) + + print(f"✓ {len(found)} archivos WAV encontrados") + print("\nEjemplos:") + for f in found[:10]: + print(f" - {f}") + + return True + + +if __name__ == "__main__": + print(generate_instrument_setup_guide()) + verify_samples() diff --git a/AbletonMCP_AI_BAK_20260328_200801/roadmap.md b/AbletonMCP_AI_BAK_20260328_200801/roadmap.md new file mode 100644 index 0000000..0d06b52 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/roadmap.md @@ -0,0 +1,215 @@ +# AbletonMCP-AI — Tech House Professional DJ Roadmap + +> Repositorio: AbletonMCP_AI | Foco: **Tech House** (122–128 BPM) +> Última actualización: 2026-03-28 + +--- + +## Estado actual del sistema + +El sistema tiene una base sólida: +- `song_generator.py` (~6k líneas): blueprints de secciones, perfiles de arrangement, bus system, gain calibration, device automation por sección +- `vector_manager.py`: semantic search + filtros de duración (evita canciones completas) +- `role_matcher.py`: validación de samples por rol con thresholds y penalizaciones +- `sample_selector.py`: selección de samples con compatibilidad BPM/key +- `audio_resampler.py`: resample layers y análisis +- `server.py` (~7k líneas): herramientas MCP expuestas al AI + +--- + +## 🔴 BUG FIXES — Prioridad crítica + +### BF-01: Track "AUDIO ATMOS 2" con canción completa +- **Problema**: El filtro de duración (max 45s) no se aplica consistentemente en todos los paths de carga +- **Causa**: `_build_audio_fallback_sample_paths` en `server.py` tiene glob patterns que ignoran el filtro de `vector_manager` +- **Fix**: Reindexar la librería con `reindex_library` y verificar que `_find_library_file` rechaza archivos donde `soundfile` lanza excepción (duración = -1) +- **Archivo**: `server.py` → `_find_library_file`, `_build_audio_fallback_sample_paths` + +### BF-02: Embeddings desactualizados post-cambios +- **Problema**: Al agregar nuevos samples a la librería, el índice `.sample_embeddings.json` no se reconstruye automáticamente +- **Fix**: Agregar un hash de fingerprint del directorio (mtime o conteo) al índice y validar en cada arranque +- **Archivo**: `vector_manager.py` → `_load_or_build_index` + +### BF-03: Colisión de nombres de track "AUDIO X 2" +- **Problema**: Cuando se crea un segundo track con el mismo nombre, Ableton le agrega "2" automáticamente y luego el sistema no lo encuentra por nombre +- **Fix**: Normalizar búsquedas de tracks usando índice numérico en vez de nombre como identificador primario +- **Archivo**: `server.py` → `_mute_tracks_for_audio_layers`, `_normalize_track_name` + +### BF-04: Linting errors restantes +- **Problema**: Múltiples errores de ruff reportados en `ruff_errors.txt` (principalmente F821 undefined names, E501 line length) +- **Fix**: Pasar `ruff check --fix` y revisar los F821 manualmente +- **Archivos**: `server.py`, `song_generator.py` + +### BF-05: `soundfile` excepción silenciosa permite archivos inválidos +- **Problema**: Si `soundfile` falla al leer un archivo, se asigna `duration = -1` pero el archivo igual puede ser insertado en escenarios de fallback +- **Fix**: En `_find_library_file`, `duration < 0` debe ser rechazado explícitamente también en el branch de fallback de `glob` +- **Archivo**: `server.py` → `_find_library_file` + +--- + +## 🟠 MEJORAS CORE — Tech House específico + +### MJ-01: Blueprints de sección optimizados para Tech House DJ +- **Qué**: Los blueprints actuales (`standard`, `extended`, `club`) son genéricos. Tech House DJ requiere intros/outros de 16-32 bars para beatmatching +- **Cambio**: + ```python + 'tech-house-dj': [ + ('INTRO DJ', 32, 8, 'intro', 1), # 32 bars solo kick+bass para mezcla + ('GROOVE A', 16, 16, 'build', 2), + ('VOX TEASE', 8, 20, 'build', 3), + ('DROP A', 32, 30, 'drop', 5), + ('BREAK', 8, 22, 'break', 1), + ('BUILD', 8, 24, 'build', 3), + ('DROP B', 32, 32, 'drop', 5), + ('OUTRO DJ', 32, 8, 'outro', 1), # 32 bars solo kick+bass para salida + ] + ``` +- **Archivo**: `song_generator.py` → `SECTION_BLUEPRINTS` + +### MJ-02: Patrones rítmicos tech house propios +- **Qué**: Los patrones de kick/hat/perc están en `create_drum_pattern` (server.py) como presets genéricos. Tech House usa swing, offbeat hats, y kicks con ghost notes +- **Cambio**: Agregar presets `'tech-house-swing'`, `'tech-house-jackin'`, `'tech-house-minimal'` con: + - Kick en 1 y 3 con variaciones en 2.5 y 3.5 + - Hi-hat con swing 16% y offbeats en 1/8 + - Clap/snare en 2 y 4 con ghost notes +- **Archivo**: `server.py` → `create_drum_pattern` + +### MJ-03: Bass lines tech house +- **Qué**: `create_bassline` genera 4 estilos genéricos. Tech House requiere basslines sincopadas y groovy +- **Cambio**: Agregar estilo `'tech-house'` con notas en posiciones off-beat, slides, y variaciones de velocidad para groove +- **Archivo**: `server.py` → `create_bassline` + +### MJ-04: Chord progressions tech house +- **Qué**: `CHORD_PROGRESSIONS` en `song_generator.py` no tiene entradas específicas para tech house +- **Cambio**: Agregar progressiones: + - Am → Fm → Gm (oscura, hipnótica) + - Dm → Am → Dm (loop de dos acordes para drop) + - Cm → Gm (minimalista con tensión) +- **Archivo**: `song_generator.py` → `CHORD_PROGRESSIONS` + +### MJ-05: Estilo Latin Tech House +- **Qué**: El sistema tiene menciones de `latin-industrial` (Eli Brown) pero no tiene patrones de percusión latina implementados +- **Cambio**: Agregar preset `'latin-tech-house'` con: + - Conga / bongo patterns como perc layer + - Bass con notas sincopadas al estilo afro-percusivo + - Vocal shots ("ey", "come on") en offbeats +- **Archivo**: `song_generator.py`, `server.py` + +### MJ-06: Genre keyword expansion en VectorManager +- **Qué**: Las búsquedas semánticas usan strings genéricos. Tech house tiene vocabulario específico +- **Cambio**: Agregar diccionario de términos preferidos por género que enriquecen el query: + ```python + GENRE_SEARCH_TERMS = { + 'tech-house': ['groovy', 'driving', 'punchy', 'jackin', 'swinging', 'hypnotic'], + 'house': ['deep', 'soulful', 'warm', 'classic'], + ... + } + ``` +- **Archivo**: `vector_manager.py` o `server.py` + +### MJ-07: Reindex automático al detectar cambios en librería +- **Qué**: El índice de embeddings solo se reconstruye manualmente. Si el usuario agrega samples, no se detectan +- **Cambio**: Al iniciar `VectorManager`, comparar el conteo de archivos actual vs el del índice. Si difieren, rebuild automático +- **Archivo**: `vector_manager.py` → `_load_or_build_index` + +--- + +## 🟡 MEJORAS DJ PRO — Funcionalidades de DJ profesional + +### DJ-01: Track Stems export / bus routing visible +- **Qué**: Un DJ profesional necesita poder exportar stems (kick, bass, music, fx) separados +- **Cambio**: Agregar herramienta `export_stems_config()` que configura los buses para exportación de stems individual, nombrando y coloreando cada bus consistentemente +- **Archivo**: `server.py` (nuevo tool) + +### DJ-02: Harmonic mixing — Camelot wheel +- **Qué**: El sistema elige keys pero no verifica compatibilidad con Camelot wheel para mezcla armónica +- **Cambio**: Agregar función `get_compatible_keys(current_key)` que devuelve keys compatibles en la rueda de Camelot (±1 tono, relativo mayor/menor). Usar en `suggest_key_change` +- **Archivo**: `server.py` → `suggest_key_change` + +### DJ-03: BPM grid automático — Sync markers +- **Qué**: Al generar una canción con intro DJ de 32 bars, colocar marcadores de Ableton (`locators`) en los puntos exactos de cada sección para que el DJ pueda saltar entre puntos +- **Cambio**: Usar el comando `create_arrangement_locator` de Ableton API para marcar cada sección +- **Archivo**: `server.py`, `Remote_Script.py` (agregar comando de socket) + +### DJ-04: Loop regions automáticas +- **Qué**: Marcar los drops como loop regions en Ableton para que el DJ pueda activar el loop con un botón +- **Cambio**: Al generar la canción, colocar punch-in / punch-out en los drops principales +- **Archivo**: `server.py` + +### DJ-05: Energy curve explícita +- **Qué**: El sistema tiene `ROLE_ACTIVITY` con valores de energía por sección pero no hay una curva visible para el usuario +- **Cambio**: Al terminar la generación, imprimir (en el manifest) la curva de energía sección a sección: `[INTRO: 25%] → [BUILD: 70%] → [DROP: 100%]...` +- **Archivo**: `server.py` → manifest / `get_generation_manifest` + +### DJ-06: Referencia de track real — Eli Brown style +- **Qué**: `REFERENCE_TRACK_PROFILES` tiene "Eli Brown - Me Gusta" definido pero no se puede cargar automáticamente una referencia para análisis A/B +- **Cambio**: Hacer funcional el sistema de referencia: si el usuario pone un archivo en `librerias/reference/`, que sea analizable e influya en BPM, key, y energy curve de la generación +- **Archivo**: `server.py`, `audio_resampler.py`, `reference_listener.py` + +--- + +## 🟢 NICE TO HAVE — Calidad de vida + +### NTH-01: Preview de canción antes de generar +- **Qué**: El sistema genera todo de golpe sin preview. Poder ver primero el "blueprint" (qué tracks, qué samples, qué estructura) antes de ejecutar +- **Cambio**: Agregar `preview_generation(genre, style, key, bpm)` que devuelve el manifest sin crear nada en Ableton +- **Archivo**: `server.py`, `song_generator.py` + +### NTH-02: Regeneración selectiva de secciones +- **Qué**: Si el drop no quedó bien, hay que regenerar todo. Debería poder regenerarse solo el drop +- **Cambio**: Agregar `regenerate_section(section_name)` que borra los clips de esa sección y los regenera +- **Archivo**: `server.py` + +### NTH-03: Historial de generaciones +- **Qué**: Solo se guarda el último manifest. Debería haber un historial de las últimas 5 generaciones +- **Cambio**: Guardar manifests en archivos `.json` con timestamp en `librerias/generations/` +- **Archivo**: `server.py` → `_store_generation_manifest` + +### NTH-04: Color coding consistente por género +- **Qué**: Los colores de tracks son estáticos. Tech House podría tener paleta propia (naranja, azul oscuro) +- **Cambio**: Agregar `GENRE_COLOR_PALETTES` y aplicar al generar tracks +- **Archivo**: `song_generator.py` → `TRACK_COLORS` + +### NTH-05: Sample diversity mejorada +- **Qué**: Si la librería tiene 3 kicks, el sistema puede usar el mismo kick en 2 generaciones seguidas +- **Cambio**: Existe `reset_diversity_memory` pero no hay persistencia entre sesiones. Guardar el historial de samples usados en un JSON local +- **Archivo**: `sample_selector.py` + +### NTH-06: Validación de routing en tiempo real +- **Qué**: `validate_set` existe pero no se llama automáticamente al generar +- **Cambio**: Al terminar `generate_track`, llamar automáticamente a `detect_common_issues` y mostrar resumen con numero de errores/warnings +- **Archivo**: `server.py` → `generate_track` + +### NTH-07: howto.md actualizado para Tech House +- **Qué**: El `howto.md` documenta el sistema genérico. Agregar sección específica de "Cómo generar Tech House profesional" con ejemplos de prompts, flujos de trabajo DJ, y settings recomendados +- **Archivo**: `howto.md` + +--- + +## Orden de ejecución recomendado + +| Prioridad | ID | Nombre | Esfuerzo | +|---|---|---|---| +| 1 | BF-01 | Full song en ATMOS track | 1h | +| 2 | BF-02 | Embeddings auto-rebuild | 2h | +| 3 | BF-03 | Colisión de nombres | 2h | +| 4 | MJ-01 | Blueprints DJ 32-bar intro/outro | 1h | +| 5 | MJ-02 | Drum patterns tech house | 2h | +| 6 | DJ-02 | Camelot wheel | 2h | +| 7 | MJ-03 | Bassline tech house | 1h | +| 8 | MJ-05 | Latin tech house preset | 3h | +| 9 | DJ-03 | BPM locators automáticos | 4h | +| 10 | DJ-06 | Referencia de track real | 4h | +| 11 | NTH-01 | Preview pre-generación | 3h | +| 12 | NTH-06 | Auto-validación post-generación | 1h | +| 13 | BF-04 | Linting cleanup | 2h | + +--- + +## Notas arquitectónicas para el salto a Tech House + +1. **BPM default**: cambiar `default_bpm` de `tech-house` de 125 a **126** (sweet spot del género actual) +2. **Key pool**: priorizar `Am`, `Fm`, `Dm` → más oscuras y groovy que las opciones actuales +3. **Swing**: el swing del 8% actual en hats es insuficiente. Tech House moderno usa 12-16% +4. **Sidechain pump**: el threshold actual de -22dB en bass bus es correcto, pero el release de 0.12s es lento. Bajar a 0.08-0.10s para más pump +5. **Atmos tracks**: el vol de 0.50 en `ROLE_MIX['atmos']` es correcto, pero usar filtros HPF altos (>1kHz) para que no compitan con el sub diff --git a/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py b/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py new file mode 100644 index 0000000..6218d10 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py @@ -0,0 +1,227 @@ +""" +Setup Returns and Master Chain for Ableton Live 12 +Creates return tracks and configures master chain +""" +import socket +import json +import time +import os +from datetime import datetime +from typing import Dict, Any + +LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\returns_master.txt" + +def log_message(message): + """Log message to file and console""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_line = f"[{timestamp}] {message}" + print(log_line) + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_line + "\n") + +class AbletonClient: + def __init__(self, host="127.0.0.1", port=9877, timeout=15.0): + self.host = host + self.port = port + self.timeout = timeout + + def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send command to Ableton runtime and get response""" + payload = json.dumps({ + "type": command_type, + "params": params or {}, + }).encode("utf-8") + b"\n" + + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(payload) + reader = sock.makefile("r", encoding="utf-8") + try: + line = reader.readline() + finally: + reader.close() + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + if not line: + return {"status": "error", "message": f"No response for command: {command_type}"} + + return json.loads(line) + +def main(): + log_message("=" * 60) + log_message("STARTING RETURNS AND MASTER CHAIN SETUP") + log_message("=" * 60) + + client = AbletonClient() + + try: + # Get current session info + log_message("\n--- Getting session info ---") + session_info = client.send("get_session_info") + log_message(f"Session: tracks={session_info.get('result', {}).get('num_tracks', 'N/A')}, returns={session_info.get('result', {}).get('num_return_tracks', 'N/A')}") + + # ======================================== + # CREATE RETURN TRACKS + # ======================================== + log_message("\n" + "=" * 60) + log_message("CREATING RETURN TRACKS") + log_message("=" * 60) + + # 1. A-REVERB (Large Hall) + log_message("\n--- Creating A-REVERB return track ---") + reverb_response = client.send("setup_return_track", { + "preset": "reverb_large", + "name": "A-REVERB" + }) + log_message(f"A-REVERB: {reverb_response.get('status')} - {json.dumps(reverb_response.get('result', reverb_response.get('message')), indent=2)}") + + if reverb_response.get("status") == "success": + return_index = reverb_response.get("result", {}).get("index", 0) + log_message(f"Setting A-REVERB volume to 0.70...") + vol_response = client.send("set_track_volume", { + "track_index": return_index, + "volume": 0.70, + "track_type": "return" + }) + log_message(f"Volume set: {vol_response.get('status')}") + + # 2. B-DELAY (Ping Pong) + log_message("\n--- Creating B-DELAY return track ---") + delay_response = client.send("setup_return_track", { + "preset": "delay_pingpong", + "name": "B-DELAY" + }) + log_message(f"B-DELAY: {delay_response.get('status')} - {json.dumps(delay_response.get('result', delay_response.get('message')), indent=2)}") + + if delay_response.get("status") == "success": + return_index = delay_response.get("result", {}).get("index", 1) + log_message(f"Setting B-DELAY volume to 0.65...") + vol_response = client.send("set_track_volume", { + "track_index": return_index, + "volume": 0.65, + "track_type": "return" + }) + log_message(f"Volume set: {vol_response.get('status')}") + + # 3. C-COMPRESSOR (Parallel compression for sidechain pumping) + log_message("\n--- Creating C-COMPRESSOR return track ---") + comp_response = client.send("setup_return_track", { + "preset": "parallel_comp", + "name": "C-COMPRESSOR" + }) + log_message(f"C-COMPRESSOR: {comp_response.get('status')} - {json.dumps(comp_response.get('result', comp_response.get('message')), indent=2)}") + + if comp_response.get("status") == "success": + return_index = comp_response.get("result", {}).get("index", 2) + log_message(f"Setting C-COMPRESSOR volume to 0.80...") + vol_response = client.send("set_track_volume", { + "track_index": return_index, + "volume": 0.80, + "track_type": "return" + }) + log_message(f"Volume set: {vol_response.get('status')}") + + # ======================================== + # MASTER CHAIN SETUP + # ======================================== + log_message("\n" + "=" * 60) + log_message("SETTING UP MASTER CHAIN") + log_message("=" * 60) + + # Get current master devices + log_message("\n--- Getting current master devices ---") + master_devices = client.send("get_devices", { + "track_type": "master", + "track_index": 0 + }) + devices_list = master_devices.get("result", []) + if isinstance(devices_list, list): + log_message(f"Current master devices: {[d.get('name', '?') if isinstance(d, dict) else str(d) for d in devices_list]}") + else: + log_message(f"Master devices response: {master_devices}") + + # Setup master chain + log_message("\n--- Loading master chain devices ---") + client.timeout = 30.0 + master_chain_response = client.send("setup_master_chain", { + "devices": ["Utility", "EQ Eight", "Compressor", "Limiter"], + "parameters": { + "Utility": { + "Gain": 0.0 + }, + "EQ Eight": { + "Mode": "Stereo" + }, + "Compressor": { + "Threshold": -18.0, + "Ratio": 2.0, + "Attack": 10.0, + "Release": 80.0, + "Makeup": 2.0 + }, + "Limiter": { + "Ceiling": -0.3, + "Release": 50.0 + } + } + }) + log_message(f"Master chain: {master_chain_response.get('status')} - {json.dumps(master_chain_response.get('result', master_chain_response.get('message')), indent=2)}") + + # Set master volume to 0.85 + log_message("\n--- Setting master volume to 0.85 ---") + master_vol_response = client.send("set_track_volume", { + "track_index": 0, + "volume": 0.85, + "track_type": "master" + }) + log_message(f"Master volume: {master_vol_response.get('status')}") + + # ======================================== + # VERIFICATION + # ======================================== + log_message("\n" + "=" * 60) + log_message("VERIFICATION") + log_message("=" * 60) + + # Get final session info + log_message("\n--- Final session info ---") + final_session = client.send("get_session_info") + result = final_session.get("result", {}) + log_message(f"Tracks: {result.get('num_tracks')}, Returns: {result.get('num_return_tracks')}, Scenes: {result.get('num_scenes')}") + + # Get final master devices + log_message("\n--- Final master devices ---") + final_master = client.send("get_devices", { + "track_type": "master", + "track_index": 0 + }) + devices_list = final_master.get("result", []) + if isinstance(devices_list, list): + for d in devices_list: + if isinstance(d, dict): + log_message(f" - {d.get('name', '?')}") + + # Verify return tracks + log_message("\n--- Return tracks ---") + for i in range(3): + ret_info = client.send("get_track_info", { + "track_index": i, + "track_type": "return" + }) + result = ret_info.get("result", {}) + log_message(f" Return {i}: {result.get('name', '?')} - Volume: {result.get('volume', '?'):.2f}" if isinstance(result.get('volume'), (int, float)) else f" Return {i}: {result.get('name', '?')}") + + log_message("\n" + "=" * 60) + log_message("SETUP COMPLETE") + log_message("=" * 60) + + except Exception as e: + log_message(f"Error: {e}") + import traceback + log_message(traceback.format_exc()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/start_server.bat b/AbletonMCP_AI_BAK_20260328_200801/start_server.bat new file mode 100644 index 0000000..8d8be55 --- /dev/null +++ b/AbletonMCP_AI_BAK_20260328_200801/start_server.bat @@ -0,0 +1,27 @@ +@echo off +echo ============================================ +echo AbletonMCP-AI Server +echo ============================================ +echo. +echo Iniciando servidor MCP... +echo Conectando a Ableton en localhost:9877 +echo. +echo Asegurate de que: +echo 1. Ableton Live 12 esta abierto +echo 2. El Control Surface 'AbletonMCP_AI' esta seleccionado +echo en Preferencias ^> Link/Tempo/MIDI +echo. +echo Presiona Ctrl+C para detener +echo ============================================ +echo. + +cd /d "%~dp0\MCP_Server" + +python server.py +if errorlevel 1 ( + echo. + echo ERROR: No se pudo iniciar el servidor + echo Verifica que Python esta instalado y en el PATH + echo. + pause +) diff --git a/Axiom_25_Classic/Preset.syx b/Axiom_25_Classic/Preset.syx new file mode 100644 index 0000000..5a36021 Binary files /dev/null and b/Axiom_25_Classic/Preset.syx differ diff --git a/Axiom_49_61_Classic/Preset.syx b/Axiom_49_61_Classic/Preset.syx new file mode 100644 index 0000000..7da2273 Binary files /dev/null and b/Axiom_49_61_Classic/Preset.syx differ diff --git a/BCF2000/Preset.syx b/BCF2000/Preset.syx new file mode 100644 index 0000000..f57e821 Binary files /dev/null and b/BCF2000/Preset.syx differ diff --git a/BCR2000/Preset.syx b/BCR2000/Preset.syx new file mode 100644 index 0000000..df3e922 Binary files /dev/null and b/BCR2000/Preset.syx differ diff --git a/KONTROL49/Preset.syx b/KONTROL49/Preset.syx new file mode 100644 index 0000000..162dcac Binary files /dev/null and b/KONTROL49/Preset.syx differ diff --git a/MPD32/Preset.syx b/MPD32/Preset.syx new file mode 100644 index 0000000..f279ce0 Binary files /dev/null and b/MPD32/Preset.syx differ diff --git a/MPK25/Preset.syx b/MPK25/Preset.syx new file mode 100644 index 0000000..f024608 Binary files /dev/null and b/MPK25/Preset.syx differ diff --git a/MPK49/Preset.syx b/MPK49/Preset.syx new file mode 100644 index 0000000..d8d9021 Binary files /dev/null and b/MPK49/Preset.syx differ diff --git a/MPK61/Preset.syx b/MPK61/Preset.syx new file mode 100644 index 0000000..c34941f Binary files /dev/null and b/MPK61/Preset.syx differ diff --git a/MPK88/Preset.syx b/MPK88/Preset.syx new file mode 100644 index 0000000..4ce9e09 Binary files /dev/null and b/MPK88/Preset.syx differ diff --git a/Push/Preset.syx b/Push/Preset.syx new file mode 100644 index 0000000..5554320 Binary files /dev/null and b/Push/Preset.syx differ diff --git a/Push/Setup.syx b/Push/Setup.syx new file mode 100644 index 0000000..46b5814 Binary files /dev/null and b/Push/Setup.syx differ diff --git a/Push2/firmware/app_push2_stable_1.0.71.upgrade b/Push2/firmware/app_push2_stable_1.0.71.upgrade new file mode 100644 index 0000000..231a0a3 Binary files /dev/null and b/Push2/firmware/app_push2_stable_1.0.71.upgrade differ diff --git a/Roland_A_PRO/Preset.syx b/Roland_A_PRO/Preset.syx new file mode 100644 index 0000000..284fd5e Binary files /dev/null and b/Roland_A_PRO/Preset.syx differ diff --git a/ableton-mcp-ai b/ableton-mcp-ai new file mode 160000 index 0000000..6283885 --- /dev/null +++ b/ableton-mcp-ai @@ -0,0 +1 @@ +Subproject commit 628388575cd30175d645a2d477991493b3aff19d diff --git a/diagnostico_wsl.py b/diagnostico_wsl.py new file mode 100644 index 0000000..674527a --- /dev/null +++ b/diagnostico_wsl.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Diagnóstico completo de conectividad Ableton <-> WSL +""" +import socket +import subprocess +import sys +import os + +def run_cmd(cmd, description): + """Ejecuta un comando y muestra el resultado""" + print(f"\n{'='*60}") + print(f"🔍 {description}") + print(f"{'='*60}") + print(f"Comando: {cmd}") + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10) + if result.stdout: + print(f"STDOUT:\n{result.stdout}") + if result.stderr: + print(f"STDERR:\n{result.stderr}") + return result.returncode == 0 + except Exception as e: + print(f"❌ Error: {e}") + return False + +def test_socket_connection(host, port, description): + """Prueba conexión socket""" + print(f"\n{'='*60}") + print(f"🔌 {description}") + print(f"{'='*60}") + print(f"Probando: {host}:{port}") + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((host, port)) + if result == 0: + print(f"✅ Conexión exitosa a {host}:{port}") + sock.close() + return True + else: + print(f"❌ No se puede conectar a {host}:{port}") + print(f" Código de error: {result}") + if result == 111: + print(" (111 = Connection refused - nadie escucha en ese puerto)") + elif result == 113: + print(" (113 = No route to host - problema de red)") + elif result == 110: + print(" (110 = Connection timed out - firewall o no accesible)") + sock.close() + return False + except Exception as e: + print(f"❌ Error: {e}") + return False + +def get_network_info(): + """Obtiene información de red de WSL""" + print(f"\n{'='*60}") + print(f"🌐 Información de red WSL") + print(f"{'='*60}") + + # IP de WSL + try: + hostname = socket.gethostname() + ip_wsl = socket.getaddrinfo(hostname, None, socket.AF_INET)[0][4][0] + print(f"IP de WSL: {ip_wsl}") + except: + print("No se pudo obtener IP de WSL") + + # IP de Windows (desde resolv.conf) + try: + with open('/etc/resolv.conf', 'r') as f: + for line in f: + if line.startswith('nameserver'): + ip_windows = line.split()[1] + print(f"IP de Windows (resolv.conf): {ip_windows}") + break + except Exception as e: + print(f"No se pudo leer resolv.conf: {e}") + + # Gateway + try: + result = subprocess.run(['ip', 'route', 'show'], capture_output=True, text=True) + print(f"\nRutas de red:") + print(result.stdout) + except: + pass + +def test_windows_ports(): + """Prueba puertos en Windows desde WSL""" + print(f"\n{'='*60}") + print(f"🔍 Probando puertos en Windows desde WSL") + print(f"{'='*60}") + + # Intentar conectar desde WSL a Windows en diferentes IPs + ips_to_test = [ + "127.0.0.1", # Localhost (solo funciona en WSL1) + "172.19.0.1", # Gateway WSL + "10.255.255.254", # Windows (desde resolv.conf) + "192.168.1.1", # Router común + ] + + # Detectar IPs reales + try: + result = subprocess.run(['ip', 'route', 'show'], capture_output=True, text=True) + for line in result.stdout.split('\n'): + if 'default' in line: + parts = line.split() + if 'via' in parts: + idx = parts.index('via') + gateway = parts[idx + 1] + if gateway not in ips_to_test: + ips_to_test.insert(0, gateway) + print(f"Añadida IP de gateway: {gateway}") + except: + pass + + for ip in ips_to_test: + test_socket_connection(ip, 9877, f"Conexión a {ip}:9877") + test_socket_connection(ip, 9879, f"Conexión a {ip}:9879 (M4L)") + +def check_ableton_log(): + """Verifica el log de Ableton""" + print(f"\n{'='*60}") + print(f"📋 Verificando Log de Ableton") + print(f"{'='*60}") + + # Convertir path de Windows a WSL + log_path = "/mnt/c/Users/ren/AppData/Roaming/Ableton/Live 12.0.15/Preferences/Log.txt" + + if os.path.exists(log_path): + print(f"✅ Log encontrado: {log_path}") + try: + # Leer últimas 50 líneas + result = subprocess.run(['tail', '-50', log_path], capture_output=True, text=True) + print(f"\nÚltimas 50 líneas del log:") + print("-" * 60) + print(result.stdout) + print("-" * 60) + + # Buscar mensajes relevantes + if 'AbletonMCP' in result.stdout or '9877' in result.stdout: + print("✅ Encontradas referencias a AbletonMCP en el log") + else: + print("⚠️ No se encontraron referencias a AbletonMCP en las últimas líneas") + print(" Esto puede significar que el remote script no se cargó") + except Exception as e: + print(f"❌ Error leyendo log: {e}") + else: + print(f"❌ Log no encontrado en: {log_path}") + print(" Verifica la ruta del log de Ableton") + +def check_remote_script(): + """Verifica que el remote script existe""" + print(f"\n{'='*60}") + print(f"📁 Verificando Remote Script") + print(f"{'='*60}") + + script_path = "/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/__init__.py" + + if os.path.exists(script_path): + print(f"✅ Remote script encontrado: {script_path}") + + # Verificar que tiene el socket server + try: + with open(script_path, 'r') as f: + content = f.read() + if 'socket' in content and '9877' in content: + print("✅ Remote script contiene código de socket server") + if '0.0.0.0' in content or 'DEFAULT_HOST' in content: + print("✅ Configurado para escuchar en todas las interfaces") + else: + print("⚠️ Puede estar configurado solo para localhost") + else: + print("❌ Remote script no parece tener código de socket") + except Exception as e: + print(f"Error leyendo script: {e}") + else: + print(f"❌ Remote script NO encontrado: {script_path}") + +def main(): + print("="*60) + print("🔧 DIAGNÓSTICO DE CONECTIVIDAD ABLETON MCP") + print("="*60) + print(f"Fecha: {subprocess.run(['date'], capture_output=True, text=True).stdout.strip()}") + + get_network_info() + check_remote_script() + check_ableton_log() + test_windows_ports() + + print(f"\n{'='*60}") + print("📊 RESUMEN DEL DIAGNÓSTICO") + print(f"{'='*60}") + print(""" +Si todas las conexiones fallaron con "Connection refused" (111): + → El remote script no está corriendo o no escucha en la red + → Solución: Verifica que Ableton tenga cargado AbletonMCP_AI en Preferencias → MIDI + +Si falla con "No route to host" (113) o timeout (110): + → Problema de red entre WSL y Windows + → Solución: Configurar firewall de Windows o usar WSL1 + +Recomendaciones: +1. En Ableton: Preferencias → MIDI → Control Surfaces → Seleccionar AbletonMCP_AI +2. En Windows (PowerShell Admin): netsh advfirewall firewall add rule name="AbletonMCP-AI" dir=in action=allow protocol=TCP localport=9877 +3. Reiniciar Ableton Live después de cambios + """) + +if __name__ == "__main__": + main() diff --git a/mcp_1429/server.py b/mcp_1429/server.py new file mode 100644 index 0000000..5a42d21 --- /dev/null +++ b/mcp_1429/server.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +MCP Server 1429 - Servidor de prueba +""" +import json +import sys + +def log(msg): + """Log to stderr (stdout is used for MCP protocol)""" + print(f"[1429] {msg}", file=sys.stderr, flush=True) + +def send_response(response): + """Send JSON-RPC response to stdout""" + json_str = json.dumps(response) + print(json_str, flush=True) + +def main(): + log("MCP Server 1429 iniciado") + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + method = request.get("method", "") + request_id = request.get("id") + + log(f"Request: {method}") + + # Handle initialize + if method == "initialize": + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "1429", + "version": "1.0.0" + } + } + } + send_response(response) + + # Handle initialized notification + elif method == "notifications/initialized": + log("Client initialized") + + # Handle tools/list + elif method == "tools/list": + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "hola", + "description": "Saluda y confirma que el MCP esta funcionando", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ] + } + } + send_response(response) + + # Handle tools/call + elif method == "tools/call": + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [ + { + "type": "text", + "text": "hola! mcp funcionando" + } + ] + } + } + send_response(response) + + else: + # Unknown method + if request_id: + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32601, + "message": f"Method not found: {method}" + } + } + send_response(response) + + except json.JSONDecodeError as e: + log(f"JSON error: {e}") + except Exception as e: + log(f"Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mcp_wrapper.bat b/mcp_wrapper.bat new file mode 100644 index 0000000..16e067a --- /dev/null +++ b/mcp_wrapper.bat @@ -0,0 +1,11 @@ +@echo off +REM Wrapper para ejecutar AbletonMCP-AI Server en opencode +REM Redirige stderr a un archivo de log para mantener stdout limpio + +cd /d "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts" + +set PYTHONIOENCODING=utf-8 +set PYTHONUNBUFFERED=1 +set PYTHONPATH=C:/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI + +python "AbletonMCP_AI/MCP_Server/server.py" --transport stdio 2>>"C:/Users/ren/opencode_mcp_error.log" diff --git a/mcp_wrapper.py b/mcp_wrapper.py new file mode 100644 index 0000000..1919a9c --- /dev/null +++ b/mcp_wrapper.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +""" +Wrapper para mantener el servidor MCP vivo +""" +import sys +import os +import asyncio + +# Añadir el path del proyecto +sys.path.insert(0, r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI') + +# Importar el servidor +from MCP_Server.server import mcp + +if __name__ == "__main__": + # Iniciar el servidor MCP con stdio + mcp.run(transport="stdio") diff --git a/microKONTROL/Preset.syx b/microKONTROL/Preset.syx new file mode 100644 index 0000000..f42a2f6 Binary files /dev/null and b/microKONTROL/Preset.syx differ diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2508f89 --- /dev/null +++ b/opencode.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow", + "mcp": { + "ableton-mcp-ai": { + "type": "local", + "command": ["python", "C:/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/MCP_Server/server.py", "--transport", "stdio"], + "enabled": true + } + } +} diff --git a/place_perc_audio.py b/place_perc_audio.py new file mode 100644 index 0000000..c465819 --- /dev/null +++ b/place_perc_audio.py @@ -0,0 +1,96 @@ +import socket +import json +import os + +def send_command(cmd_type, params): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + try: + sock.connect(('127.0.0.1', 9877)) + request = json.dumps({'type': cmd_type, 'params': params}) + sock.sendall((request + '\n').encode('utf-8')) + response = b'' + while True: + chunk = sock.recv(4096) + if not chunk: + break + response += chunk + if b'\n' in chunk: + break + return json.loads(response.decode('utf-8')) + except Exception as e: + return {'status': 'error', 'message': f'Socket error: {str(e)}'} + finally: + sock.close() + +samples = { + 26: { + 'name': 'PERC LOOP 1', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\perc\Perc_Loop_01_Fm_125.wav', + 'positions': [0, 8, 16, 24, 32, 40, 48, 56], + 'volume': 0.78 + }, + 27: { + 'name': 'PERC LOOP 2', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\perc\Perc_Loop_03_A#_125.wav', + 'positions': [0, 16, 32, 48, 64, 80], + 'volume': 0.75 + }, + 28: { + 'name': 'TOP LOOP', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\loop_other\Top_Loop_01_Any_125.wav', + 'positions': [0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60], + 'volume': 0.72 + }, + 29: { + 'name': 'SHAKER', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\perc\Kit_03_Shaker_Cm_125.wav', + 'positions': [0, 8, 16, 24, 32, 40, 48, 56], + 'volume': 0.70 + }, + 30: { + 'name': 'CONGA', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\perc\BBH - Primer Impacto - Tom Loop A# 124 Bpm 7.wav', + 'positions': [8, 24, 40, 56], + 'volume': 0.75 + }, + 31: { + 'name': 'COWBELL', + 'file': r'C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\perc\Perc_Loop_06_Dm_125.wav', + 'positions': [4, 12, 20, 28, 36, 44], + 'volume': 0.75 + } +} + +log_path = r'C:\Users\ren\Documents\Ableton\Logs\percussion_group.txt' + +print('Placing audio on correct percussion tracks (26-31)...') +results = [] + +for track_idx, info in samples.items(): + print(f'\nProcessing {info["name"]} (track {track_idx})...') + + result = send_command('create_arrangement_audio_pattern', { + 'track_index': track_idx, + 'file_path': info['file'], + 'positions': info['positions'] + }) + results.append({'track': info['name'], 'track_idx': track_idx, 'result': result}) + print(f' Audio: {result.get("status", "unknown")}') + + vol_result = send_command('set_track_volume', {'index': track_idx, 'volume': info['volume']}) + print(f' Volume: {vol_result.get("status", "unknown")} ({info["volume"]})') + + with open(log_path, 'a', encoding='utf-8') as f: + f.write(f'\n{info["name"]} (track {track_idx}):\n') + f.write(f' File: {os.path.basename(info["file"])}\n') + f.write(f' Positions: {info["positions"]}\n') + f.write(f' Volume: {info["volume"]}\n') + f.write(f' Result: {json.dumps(result, indent=2)}\n') + +with open(log_path, 'a', encoding='utf-8') as f: + f.write('\n=== FINAL PERCUSSION GROUP SUMMARY ===\n') + for r in results: + status = r['result'].get('status', 'unknown') + f.write(f'Track {r["track_idx"]} {r["track"]}: {status}\n') + print(f'{r["track"]}: {status}') \ No newline at end of file diff --git a/restart_ableton.bat b/restart_ableton.bat new file mode 100644 index 0000000..8ccbac0 --- /dev/null +++ b/restart_ableton.bat @@ -0,0 +1,20 @@ +@echo off +echo Reiniciando Ableton Live 12... +echo. + +echo Deteniendo procesos de Ableton... +taskkill /F /IM "Ableton Live 12 Suite.exe" >nul 2>&1 +taskkill /F /IM "AbletonPushCpl.exe" >nul 2>&1 +taskkill /F /IM "Ableton Index.exe" >nul 2>&1 + +echo Esperando 3 segundos... +timeout /t 3 /nobreak >nul + +echo Iniciando Ableton Live 12... +start "" "C:\Program Files\Ableton\Live 12 Suite\Program\Ableton Live 12 Suite.exe" + +echo. +echo Ableton se ha reiniciado. +echo Espere 10-15 segundos para que cargue completamente. +echo. +echo Luego puede usar las herramientas MCP. diff --git a/set_input_routing.py b/set_input_routing.py new file mode 100644 index 0000000..fa37749 --- /dev/null +++ b/set_input_routing.py @@ -0,0 +1,46 @@ +import socket +import json + +def send_command(cmd_type, params): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + try: + sock.connect(('127.0.0.1', 9877)) + request = json.dumps({'type': cmd_type, 'params': params}) + sock.sendall((request + '\n').encode('utf-8')) + response = b'' + while True: + chunk = sock.recv(4096) + if not chunk: + break + response += chunk + if b'\n' in chunk: + break + return json.loads(response.decode('utf-8')) + except Exception as e: + return {'status': 'error', 'message': f'Socket error: {str(e)}'} + finally: + sock.close() + +log_path = r'C:\Users\ren\Documents\Ableton\Logs\percussion_group.txt' + +tracks = { + 26: 'PERC LOOP 1', + 27: 'PERC LOOP 2', + 28: 'TOP LOOP', + 29: 'SHAKER', + 30: 'CONGA', + 31: 'COWBELL' +} + +print('Setting input routing to "No Input" for percussion tracks...') +for track_idx, name in tracks.items(): + result = send_command('set_track_input_routing', {'index': track_idx, 'routing_name': 'No Input'}) + print(f' {name} (track {track_idx}): {result.get("status", "unknown")}') + +with open(log_path, 'a', encoding='utf-8') as f: + f.write('\n=== INPUT ROUTING SET ===\n') + for track_idx, name in tracks.items(): + f.write(f'{name} (track {track_idx}): No Input\n') + +print('\nDone!') \ No newline at end of file diff --git a/start_claude_glm5.sh b/start_claude_glm5.sh new file mode 100644 index 0000000..c46e0d2 --- /dev/null +++ b/start_claude_glm5.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# start_claude_glm5.sh - Inicia Claude Code con GLM-5 y modo equipos + +export ANTHROPIC_BASE_URL="https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" +export ANTHROPIC_AUTH_TOKEN="sk-sp-e87cea7b587c4af09e465726b084f41b" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" +export ANTHROPIC_MODEL="glm-5" +export ANTHROPIC_SMALL_FAST_MODEL="glm-5" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm-5" +export ANTHROPIC_DEFAULT_SONNET_MODEL="glm-5" +export ANTHROPIC_DEFAULT_OPUS_MODEL="glm-5" +export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS="1" + +# Ir al directorio del proyecto +cd "/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts" + +echo "=== Claude Code + GLM-5 + Agent Teams ===" +echo "Model: glm-5" +echo "Base URL: $ANTHROPIC_BASE_URL" +echo "Agent Teams: enabled" +echo "MCP Server: ableton-mcp-ai" +echo "" + +# Iniciar Claude Code +claude --dangerously-skip-permissions --teammate-mode tmux --effort max \ No newline at end of file diff --git a/start_mcp.bat b/start_mcp.bat new file mode 100644 index 0000000..3a6220a --- /dev/null +++ b/start_mcp.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" +set PYTHONPATH=C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server;C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI +python server.py --transport stdio > server.log 2>&1 diff --git a/temp_socket_cmd.py b/temp_socket_cmd.py new file mode 100644 index 0000000..6f9f089 --- /dev/null +++ b/temp_socket_cmd.py @@ -0,0 +1,23 @@ +import socket +import json + +def send_cmd(cmd): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(('127.0.0.1', 9877)) + s.sendall(json.dumps(cmd).encode() + b'\x00') + data = b'' + while True: + chunk = s.recv(8192) + if not chunk: + break + if b'\x00' in chunk: + data += chunk.replace(b'\x00', b'') + break + data += chunk + s.close() + return data.decode() + +# Get tracks first +result = send_cmd({'action': 'get_tracks'}) +print("=== TRACKS ===") +print(result[:3000]) diff --git a/validate_audio_resampler.py b/validate_audio_resampler.py new file mode 100644 index 0000000..72ca77d --- /dev/null +++ b/validate_audio_resampler.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Script de validacion para el Audio Resampler. +Verifica que: +1. Las 4 funciones standalone existan y sean importables +2. La clase AudioResampler funcione correctamente +3. El cache LRU opera correctamente +4. La integracion con build_transition_layers funcione +""" + +import sys +import os + +# Agregar el path del MCP_Server +script_dir = os.path.dirname(os.path.abspath(__file__)) +mcp_server_dir = os.path.join(script_dir, "AbletonMCP_AI", "MCP_Server") +sys.path.insert(0, mcp_server_dir) + +def test_imports(): + """Test 1: Verificar que todas las funciones se pueden importar""" + print("=" * 60) + print("TEST 1: Verificacion de imports") + print("=" * 60) + + try: + from audio_resampler import ( + AudioResampler, + create_reverse_fx, + create_riser_fx, + create_downlifter_fx, + create_stutter_fx, + ) + print("[OK] Todos los imports exitosos") + print(f" - AudioResampler: {AudioResampler}") + print(f" - create_reverse_fx: {create_reverse_fx}") + print(f" - create_riser_fx: {create_riser_fx}") + print(f" - create_downlifter_fx: {create_downlifter_fx}") + print(f" - create_stutter_fx: {create_stutter_fx}") + return True + except Exception as e: + print(f"[ERROR] Fallo en imports: {e}") + import traceback + traceback.print_exc() + return False + + +def test_class_structure(): + """Test 2: Verificar estructura de la clase AudioResampler""" + print("\n" + "=" * 60) + print("TEST 2: Estructura de AudioResampler") + print("=" * 60) + + try: + from audio_resampler import AudioResampler + + # Verificar metodos privados de FX + required_methods = [ + '_render_reverse_fx', + '_render_riser', + '_render_downlifter', + '_render_stutter', + '_load_audio', + '_write_audio', + '_output_path', + 'build_transition_layers', + 'cache_stats', + 'clear_cache', + ] + + resampler = AudioResampler() + missing = [] + for method in required_methods: + if not hasattr(resampler, method): + missing.append(method) + else: + print(f"[OK] Metodo encontrado: {method}") + + if missing: + print(f"[ERROR] Metodos faltantes: {missing}") + return False + + # Verificar constantes de cache + print(f"[OK] Cache limit: {resampler._CACHE_LIMIT}") + print(f"[OK] Cache max age: {resampler._CACHE_MAX_AGE_S}s") + print(f"[OK] Default peak: {resampler._DEFAULT_PEAK}") + + return True + except Exception as e: + print(f"[ERROR] Fallo en estructura: {e}") + import traceback + traceback.print_exc() + return False + + +def test_cache_system(): + """Test 3: Verificar sistema de cache""" + print("\n" + "=" * 60) + print("TEST 3: Sistema de Cache LRU") + print("=" * 60) + + try: + from audio_resampler import AudioResampler + + resampler = AudioResampler() + + # Verificar cache inicial vacio + stats = resampler.cache_stats() + print(f"[OK] Cache stats inicial: entries={stats['entries']}, hits={stats['hits']}") + + # Verificar que el cache funciona (incluso sin audio) + assert stats['entries'] == 0, "Cache deberia estar vacio al inicio" + assert stats['max_entries'] == 50, "Cache limit deberia ser 50" + assert stats['max_age_s'] == 1800.0, "Cache max age deberia ser 1800s" + + print("[OK] Sistema de cache operando correctamente") + return True + except Exception as e: + print(f"[ERROR] Fallo en cache: {e}") + import traceback + traceback.print_exc() + return False + + +def test_transition_layers_structure(): + """Test 4: Verificar estructura de build_transition_layers""" + print("\n" + "=" * 60) + print("TEST 4: Estructura de build_transition_layers") + print("=" * 60) + + try: + from audio_resampler import AudioResampler + + resampler = AudioResampler() + + # Probar con un plan vacio + empty_plan = {"matches": {}} + sections = [ + {"kind": "intro", "name": "Intro", "beats": 16}, + {"kind": "build", "name": "Build Up", "beats": 16}, + {"kind": "drop", "name": "Drop A", "beats": 32}, + ] + + layers = resampler.build_transition_layers(empty_plan, sections, 128.0) + + # Verificar que retorna una lista + assert isinstance(layers, list), "Debe retornar una lista" + print(f"[OK] build_transition_layers retorna lista: {len(layers)} capas") + + # Verificar estructura de capas (si hay alguna) + for i, layer in enumerate(layers): + required_keys = ['name', 'file_path', 'positions', 'color', 'volume', 'source', 'generated'] + missing = [k for k in required_keys if k not in layer] + if missing: + print(f"[WARN] Capa {i} falta keys: {missing}") + else: + print(f"[OK] Capa {i} '{layer['name']}' estructura correcta") + + print("[OK] build_transition_layers estructura correcta") + return True + except Exception as e: + print(f"[ERROR] Fallo en transition_layers: {e}") + import traceback + traceback.print_exc() + return False + + +def test_function_signatures(): + """Test 5: Verificar firmas de funciones standalone""" + print("\n" + "=" * 60) + print("TEST 5: Firmas de funciones standalone") + print("=" * 60) + + try: + from audio_resampler import ( + create_reverse_fx, + create_riser_fx, + create_downlifter_fx, + create_stutter_fx, + ) + import inspect + + functions = [ + ('create_reverse_fx', create_reverse_fx), + ('create_riser_fx', create_riser_fx), + ('create_downlifter_fx', create_downlifter_fx), + ('create_stutter_fx', create_stutter_fx), + ] + + for name, func in functions: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + + # Verificar parametros minimos + assert 'source_path' in params, f"{name} debe tener source_path" + assert 'output_path' in params, f"{name} debe tener output_path" + + print(f"[OK] {name} firma: {sig}") + + print("[OK] Todas las funciones tienen firmas correctas") + return True + except Exception as e: + print(f"[ERROR] Fallo en firmas: {e}") + import traceback + traceback.print_exc() + return False + + +def main(): + """Ejecutar todos los tests""" + print("\n" + "=" * 60) + print("VALIDACION DE AUDIO RESAMPLER") + print("=" * 60) + + results = [ + ("Imports", test_imports), + ("Estructura de clase", test_class_structure), + ("Sistema de cache", test_cache_system), + ("Transition layers", test_transition_layers_structure), + ("Firmas de funciones", test_function_signatures), + ] + + passed = 0 + failed = 0 + + for name, test_func in results: + try: + if test_func(): + passed += 1 + else: + failed += 1 + except Exception as e: + print(f"\n[ERROR CRITICO] {name}: {e}") + failed += 1 + + print("\n" + "=" * 60) + print("RESUMEN DE VALIDACION") + print("=" * 60) + print(f"Tests pasados: {passed}/{len(results)}") + print(f"Tests fallidos: {failed}/{len(results)}") + + if failed == 0: + print("\n[OK] Audio Resampler validado exitosamente!") + return 0 + else: + print("\n[ERROR] Algunos tests fallaron") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/validate_script.py b/validate_script.py new file mode 100644 index 0000000..e23b9b4 --- /dev/null +++ b/validate_script.py @@ -0,0 +1,43 @@ +import socket +import json + +HOST = "127.0.0.1" +PORT = 9877 +MESSAGE_TERMINATOR = b"\n" + +def send_cmd(cmd_type, params=None): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect((HOST, PORT)) + payload = json.dumps({"type": cmd_type, "params": params or {}}, separators=(",", ":")).encode("utf-8") + MESSAGE_TERMINATOR + s.sendall(payload) + data = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + if MESSAGE_TERMINATOR in chunk: + data += chunk.replace(MESSAGE_TERMINATOR, b"") + break + data += chunk + s.close() + if data: + return json.loads(data.decode("utf-8")) + return None + +# Validate +print("=== VALIDATE SET ===") +validate = send_cmd("validate_set", {"check_clips": True, "check_gain": True, "check_routing": True}) +print(json.dumps(validate, indent=2)) + +print("\n=== DIAGNOSE SET ===") +diagnose = send_cmd("diagnose_generated_set") +print(json.dumps(diagnose, indent=2)) + +print("\n=== TRACKS STATUS ===") +tracks = send_cmd("get_tracks") +if tracks: + for i, track in enumerate(tracks.get('result', [])): + name = track.get('name', 'Unknown') + arr = track.get('arrangement_clip_count', 0) + sess = track.get('session_clip_count', 0) + print(f" {i}: {name} - Session: {sess}, Arrangement: {arr}")