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 <noreply@anthropic.com>
This commit is contained in:
renato97
2026-03-28 22:53:10 -03:00
commit 6ec8663954
120 changed files with 59101 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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',
])

View File

@@ -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 <archivo_de_audio>")
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)

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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},
}

File diff suppressed because it is too large Load Diff

View File

@@ -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())

View File

@@ -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

View File

@@ -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())

View File

@@ -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** — IIVVI (tonal), iiVI (jazz), iVIIVIVII (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

View File

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

View File

@@ -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 <directorio_de_samples>")
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)")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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)

Binary file not shown.

View File

@@ -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())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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())

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -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` |

View File

@@ -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 <library_dir> [search_query]")