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:
39
AbletonMCP_AI/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md
Normal file
39
AbletonMCP_AI/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md
Normal 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.
|
||||
203
AbletonMCP_AI/MCP_Server/SAMPLE_SYSTEM_README.md
Normal file
203
AbletonMCP_AI/MCP_Server/SAMPLE_SYSTEM_README.md
Normal 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.
|
||||
26
AbletonMCP_AI/MCP_Server/__init__.py
Normal file
26
AbletonMCP_AI/MCP_Server/__init__.py
Normal 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',
|
||||
])
|
||||
681
AbletonMCP_AI/MCP_Server/audio_analyzer.py
Normal file
681
AbletonMCP_AI/MCP_Server/audio_analyzer.py
Normal 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)
|
||||
117
AbletonMCP_AI/MCP_Server/audio_organizer.py
Normal file
117
AbletonMCP_AI/MCP_Server/audio_organizer.py
Normal 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)
|
||||
2527
AbletonMCP_AI/MCP_Server/audio_resampler.py
Normal file
2527
AbletonMCP_AI/MCP_Server/audio_resampler.py
Normal file
File diff suppressed because it is too large
Load Diff
381
AbletonMCP_AI/MCP_Server/diversity_memory.py
Normal file
381
AbletonMCP_AI/MCP_Server/diversity_memory.py
Normal 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()
|
||||
431
AbletonMCP_AI/MCP_Server/enhanced_device_automation.py
Normal file
431
AbletonMCP_AI/MCP_Server/enhanced_device_automation.py
Normal 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},
|
||||
}
|
||||
4774
AbletonMCP_AI/MCP_Server/reference_listener.py
Normal file
4774
AbletonMCP_AI/MCP_Server/reference_listener.py
Normal file
File diff suppressed because it is too large
Load Diff
264
AbletonMCP_AI/MCP_Server/reference_stem_builder.py
Normal file
264
AbletonMCP_AI/MCP_Server/reference_stem_builder.py
Normal 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())
|
||||
13
AbletonMCP_AI/MCP_Server/requirements.txt
Normal file
13
AbletonMCP_AI/MCP_Server/requirements.txt
Normal 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
|
||||
525
AbletonMCP_AI/MCP_Server/retrieval_benchmark.py
Normal file
525
AbletonMCP_AI/MCP_Server/retrieval_benchmark.py
Normal 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())
|
||||
508
AbletonMCP_AI/MCP_Server/roadmap.md
Normal file
508
AbletonMCP_AI/MCP_Server/roadmap.md
Normal 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** — I–IV–V–I (tonal), ii–V–I (jazz), i–VII–VI–VII (modal techno)
|
||||
- [ ] **Chord voicing** — voicings distintos por registro (close voicing en graves, open en agudos)
|
||||
- [ ] **Inversions** — usar inversiones de acordes para crear smooth voice leading entre acordes
|
||||
- [ ] **Pedal point** — nota pedal sostenida en el bass mientras los acordes cambian arriba
|
||||
- [ ] **Suspended chords** — usar sus2 y sus4 para crear tensión sin disonancia abierta
|
||||
- [ ] **Modal interchange** — préstamo de acordes de modos paralelos para color emocional
|
||||
|
||||
### 7.3 Líneas de Bajo Generadas
|
||||
- [ ] **Root note bass** — línea de bajo sobre las raíces de los acordes, rítmica y sincopada
|
||||
- [ ] **Walking bass** — línea de bajo que se mueve por grados de escala hacia cada acorde
|
||||
- [ ] **Acid bass pattern** — patrón tipo TB-303 con slides, accents y rests aleatorios dentro de escala
|
||||
- [ ] **Sub + Mid split** — separar el sub (frecuencias <80Hz) del mid-bass (80-250Hz) para procesamiento distinto
|
||||
- [ ] **Octave doubling** — doblar la línea de bajo una octava arriba para cuerpo y definición
|
||||
|
||||
### 7.4 Síntesis de Batería
|
||||
- [ ] **Kick synthesis** — generar kicks sintéticos con seno + click + pitch envelope (estilo TR-909)
|
||||
- [ ] **Snare synthesis** — ruido + tonal con parámetros de color, "crack" y "body"
|
||||
- [ ] **Hat synthesis** — ruido filtrado con envelope de decay muy corto, variaciones de apertura
|
||||
- [ ] **Clap layering** — múltiples ruidos cortos desfasados levemente para clap orgánico
|
||||
- [ ] **Transient design** — ajustar por separado el ataque y el "cuerpo" de cada drum hit
|
||||
|
||||
### 7.5 Texturas y Atmósferas Generativas
|
||||
- [ ] **Drone generation** — generar un drone en la tónica del track para dar sustento armónico
|
||||
- [ ] **Granular texture** — usar síntesis granular sobre un sample para crear texturas únicas
|
||||
- [ ] **Noise color selection** — blanco, rosado o marrón según el mood y la sección del track
|
||||
- [ ] **Stochastic modulation** — parámetros de synth que cambian aleatoriamente dentro de un rango
|
||||
- [ ] **Evolving pad** — pad que cambia lentamente de carácter a lo largo del track usando automación
|
||||
|
||||
---
|
||||
|
||||
## FASE 8 — Mastering Automático de Nivel Label
|
||||
> _Prioridad: MEDIA · Estimado: 4-5 semanas_
|
||||
|
||||
### 8.1 Target Loudness por Destino
|
||||
- [ ] **Streaming master** — -14 LUFS integrated, -1 dBFS true peak (estándar Spotify/Apple)
|
||||
- [ ] **Club master** — -6 LUFS integrated, -0.3 dBFS true peak (para sistemas PA)
|
||||
- [ ] **Broadcast master** — -23 LUFS integrated (EBU R128/ATSC A/85)
|
||||
- [ ] **Vinyl master** — limitado en sub-bass, fase mono, -12 LUFS (limitaciones físicas del vinilo)
|
||||
- [ ] **DJ DJ USB** — -9 LUFS, formato WAV 24bit para Pioneer CDJ/XDJ
|
||||
|
||||
### 8.2 Cadena de Mastering
|
||||
- [ ] **EQ de mastering** — corrección tonal amplia: leve boost de aire, corrección de resonancias
|
||||
- [ ] **Mid-side EQ** — expandir el side, comprimir el mid para imagen más profesional
|
||||
- [ ] **Multi-band compression** — 3-4 bandas de compresión suave para control de dinámica por rango
|
||||
- [ ] **Stereo enhancer** — ampliar levemente el mid-high para más espacio sin afectar el sub
|
||||
- [ ] **Tape emulation** — saturación de cinta leve en el master para calidez analógica
|
||||
- [ ] **Limiting** — limiting con lookahead de 2-8ms, attack rápido, release configurado al BPM
|
||||
- [ ] **True peak limiting** — segundo limiter post-master para garantizar true peak dentro del target
|
||||
|
||||
### 8.3 Análisis y QC del Master
|
||||
- [ ] **Loudness report** — integrated LUFS, momentary LUFS max, LRA (loudness range), true peak
|
||||
- [ ] **Spectral balance report** — gráfico comparando la distribución espectral vs referencia comercial
|
||||
- [ ] **Phase correlation** — verificar que la correlación estéreo sea positiva (>0.5) para compatibilidad mono
|
||||
- [ ] **Clipping check** — escanear el master en busca de clips o inter-sample peaks
|
||||
- [ ] **A/B comparison protocol** — comparar el master vs referencia con ganancia compensada (mismo LUFS)
|
||||
|
||||
### 8.4 Dithering y Formato Final
|
||||
- [ ] **Dithering** — aplicar dithering TPDF al convertir de 32-bit float a 16/24-bit PCM
|
||||
- [ ] **Format conversion** — WAV 24bit/48kHz (producción), WAV 16bit/44.1kHz (CD), FLAC (archivo)
|
||||
- [ ] **MP3 encoding** — export MP3 320kbps para uso en software DJ (CBR, joint stereo)
|
||||
- [ ] **Metadata embedding** — BPM, key, genre, ISRC, album art en los metadatos del archivo final
|
||||
- [ ] **File naming convention** — `[artist]_[title]_[bpm]_[key]_[version].[ext]` automático
|
||||
|
||||
### 8.5 Revisión por Ia Antes del Master
|
||||
- [ ] **Pre-master checklist** — verificar que el mix cumple con los criterios antes de masterizar
|
||||
- [ ] **Headroom verification** — el mix no supera -6 dBFS antes de entrar al master chain
|
||||
- [ ] **Low-end mono check** — confirmar que el sub es mono y el bass no supera el kick en volumen
|
||||
- [ ] **Reverb tail check** — que no haya colas de reverb que superen el tempo al final de las frases
|
||||
- [ ] **Dropout detection** — detectar silencios inesperados o glitches en el audio antes de masterizar
|
||||
|
||||
---
|
||||
|
||||
## FASE 9 — Colaboración, Versionado y Producción en Equipo
|
||||
> _Prioridad: MEDIA · Estimado: 4-6 semanas_
|
||||
|
||||
### 9.1 Versionado de Sesiones
|
||||
- [ ] **Version history** — cada sesión generada se guarda con timestamp y metadata completa
|
||||
- [ ] **Named versions** — versiones con nombre: v1_rough_mix, v2_with_drops, v3_final
|
||||
- [ ] **Diff between versions** — mostrar qué cambió entre dos versiones (BPM, key, samples usados)
|
||||
- [ ] **Rollback** — volver a cualquier versión anterior con un comando
|
||||
- [ ] **Branch system** — crear variantes paralelas de un track sin sobrescribir el original
|
||||
|
||||
### 9.2 Documentación Musical Automática
|
||||
- [ ] **Production notes** — exportar documento con todos los samples usados, BPM, key, settings
|
||||
- [ ] **Sample clearance report** — marcar qué samples son de librerías royalty-free y cuáles no
|
||||
- [ ] **Arrangement timeline** — exportar un diagrama de la estructura del track (intro, verse, drop, etc.)
|
||||
- [ ] **Plugin settings export** — guardar todos los parámetros de los devices de Ableton usados
|
||||
- [ ] **Collaboration template** — exportar el proyecto en formato que otro productor pueda retomar
|
||||
|
||||
### 9.3 Gestión de Sample Library
|
||||
- [ ] **Sample usage tracking** — registrar qué samples se usan en qué tracks
|
||||
- [ ] **Overused sample detection** — alertar si el mismo sample aparece en más de 3 tracks del mismo período
|
||||
- [ ] **Library gap analysis** — detectar qué categorías de samples son escasas en la librería
|
||||
- [ ] **Sample rating system** — votar samples (1-5 estrellas), excluir los de baja calidad de la selección
|
||||
- [ ] **Pack organization** — organizar samples por "pack" (colección de origen) para coherencia tonal
|
||||
|
||||
### 9.4 Exportación y Distribución
|
||||
- [ ] **Stem export automático** — exportar cada bus como archivo separado (drums, bass, music, vocal, fx)
|
||||
- [ ] **Stem naming convention** — nombres con rol y número de proyecto incluido
|
||||
- [ ] **ZIP release package** — empaquetar master, stems, artwork y notes en un ZIP listo para distribuir
|
||||
- [ ] **Streaming metadata** — metadata en formato compatible con DistroKid/TuneCore/CD Baby
|
||||
- [ ] **Cover art generation** — generar artwork minimalista basado en género/mood (integración DALL-E o similar)
|
||||
|
||||
### 9.5 Retroalimentación y Aprendizaje
|
||||
- [ ] **A/B testing de tracks generados** — comparar dos versiones y registrar cuál se prefiere
|
||||
- [ ] **Production log** — registro de decisiones creativas tomadas por el sistema con justificación
|
||||
- [ ] **Error pattern learning** — registrar qué parámetros produjeron resultados malos y evitarlos
|
||||
- [ ] **Style evolution tracking** — documentar cómo evoluciona el "estilo" del sistema a lo largo del tiempo
|
||||
- [ ] **External feedback integration** — formulario para que el DJ/productor califica el resultado
|
||||
|
||||
---
|
||||
|
||||
## FASE 10 — DJ Autónomo Completo
|
||||
> _Prioridad: MEDIA-BAJA · Estimado: 8-12 semanas_
|
||||
|
||||
Esta es la fase final: el sistema es capaz de planear, generar, mezclar y performar un set completo de forma completamente autónoma, con mínima intervención humana.
|
||||
|
||||
### 10.1 Generación de Set Completo End-to-End
|
||||
- [ ] **One-command set** — `generate_set(duration=60, genre='techno', mood='dark')` produce un set completo
|
||||
- [ ] **Coherent sound palette** — todos los tracks del set comparten elementos sonoros para coherencia
|
||||
- [ ] **Progression narrative** — el set cuenta una "historia" musical de apertura hasta el tema emocional
|
||||
- [ ] **Auto-transition rendering** — todas las transiciones pre-renderizadas y listas para playback
|
||||
- [ ] **Continuous mix export** — exportar el set completo como un archivo de audio sin cortes
|
||||
|
||||
### 10.2 Performance en Tiempo Real
|
||||
- [ ] **Live generation** — generar el próximo track mientras el actual está siendo tocado
|
||||
- [ ] **Real-time transition adjustment** — ajustar parámetros de transición basado en lo que está sonando
|
||||
- [ ] **Hot cue system** — colocar hot cues automáticamente en los puntos de mezcla óptimos
|
||||
- [ ] **Loop juggling AI** — el sistema decide cuándo loopear, cuándo romper el loop para máximo impacto
|
||||
- [ ] **FX performance** — disparar efectos en momentos clave (reverb throw, filter sweep) automáticamente
|
||||
|
||||
### 10.3 Respuesta a Contexto
|
||||
- [ ] **Time-of-night awareness** — detectar por reloj si es apertura, peak o cierre y adaptar la energía
|
||||
- [ ] **Venue size adaptation** — configurar para cuarto pequeño (íntimo, técnico) vs festival (más épico)
|
||||
- [ ] **Genre request handling** — el operador pide "más oscuro", "más rápido", "más groovy" en lenguaje natural
|
||||
- [ ] **Emergency handling** — si un track no carga o falla, el sistema selecciona un reemplazo en <1 segundo
|
||||
- [ ] **BPM tempo lock** — nunca salirse de un rango de BPM configurado aunque la selección lo sugiera
|
||||
|
||||
### 10.4 Inteligencia Emocional Musical
|
||||
- [ ] **Mood lexicon** — vocabulario de moods con sus características técnicas (dark = menor, lento, menos brillo)
|
||||
- [ ] **Energy trajectory** — predecir cómo va a evolucionar la energía de los próximos 20 minutos
|
||||
- [ ] **Listener journey modeling** — modelar la experiencia del oyente como una narrativa con arcos
|
||||
- [ ] **Surprise injection** — agregar momentos inesperados cada 20 minutos para mantener atención
|
||||
- [ ] **Emotional contrast** — garantizar contrastes de intensidad para que el peak moment sea más impactante
|
||||
|
||||
### 10.5 Aprendizaje Continuo
|
||||
- [ ] **Session reinforcement learning** — cada set mejora el planeamiento del siguiente
|
||||
- [ ] **Style drift detection** — detectar si el sistema tiende a repetir los mismos patrones y corrección automática
|
||||
- [ ] **Trend awareness** — analizar tracks nuevos periódicamente para mantenerse al día con el sonido actual
|
||||
- [ ] **Personal style refinement** — refinar el "DNA sonoro" del DJ basado en feedback acumulado
|
||||
- [ ] **Cross-genre inspiration** — ocasionalmente tomar elementos de géneros no habituales para innovar
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Wins (valor inmediato, 1-3 días cada uno)
|
||||
|
||||
| # | Feature | Fase | Impacto | Esfuerzo |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Side-chain kick → bass** | 1.4 | 🔥🔥🔥 | Bajo |
|
||||
| 2 | **Intro/outro de 32 bars** | 2.1 | 🔥🔥🔥 | Bajo |
|
||||
| 3 | **LUFS normalization por track** | 1.1 | 🔥🔥🔥 | Bajo |
|
||||
| 4 | **HP filter automático en intro** | 3.6 | 🔥🔥 | Bajo |
|
||||
| 5 | **Camelot Wheel key compatibility** | 5.1 | 🔥🔥 | Bajo |
|
||||
| 6 | **Crash on first beat of drop** | 2.3 | 🔥🔥 | Bajo |
|
||||
| 7 | **BPM y Key en metadata del archivo** | 8.4 | 🔥 | Bajo |
|
||||
| 8 | **Snare roll en buildup (4 bars)** | 2.3 | 🔥🔥 | Bajo |
|
||||
| 9 | **Reverb tail al salir del breakdown** | 3.1 | 🔥🔥 | Medio |
|
||||
| 10 | **Stereo mono abajo de 200Hz** | 3.5 | 🔥🔥 | Bajo |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Criterio de "DJ Profesional" — Checklist de Aceptación
|
||||
|
||||
Un sistema MCP alcanza nivel DJ profesional cuando puede superar todos estos criterios:
|
||||
|
||||
### Técnicos
|
||||
- [ ] El LUFS integrado de cada track está entre -9 y -8 dBFS (nivel club)
|
||||
- [ ] Nunca hay clipping ni distorsión no intencional en ningún track
|
||||
- [ ] El sub-bass es mono en todos los tracks generados
|
||||
- [ ] El side-chain kick→bass está funcionando y se puede escuchar claramente
|
||||
- [ ] Todas las transiciones entre tracks son musicalmente coherentes
|
||||
|
||||
### Estructurales
|
||||
- [ ] Cada track tiene al menos 32 bars de intro mezclable
|
||||
- [ ] Cada track tiene al menos 32 bars de outro mezclable
|
||||
- [ ] El drop tiene más energía que cualquier sección previa
|
||||
- [ ] El breakdown es notablemente más tranquilo que el drop
|
||||
- [ ] El buildup crea anticipación audible antes del drop
|
||||
|
||||
### DJ Performance
|
||||
- [ ] El sistema puede mezclar dos tracks en menos de 16 bars de superposición
|
||||
- [ ] El key matching garantiza que los dos tracks suenan harmónicos juntos
|
||||
- [ ] Un set de 60 minutos mantiene un arco de energía coherente
|
||||
- [ ] No se repite el mismo sample prominente dentro del mismo set
|
||||
- [ ] El set se puede tocar en una pista sin vergüenza
|
||||
|
||||
### Emocional
|
||||
- [ ] Hay un "momento" memorable en cada track (un riff, un drop, un silencio)
|
||||
- [ ] El set tiene un "peak moment" claramente identificable
|
||||
- [ ] La música crea una respuesta física (ganas de mover los pies)
|
||||
- [ ] Hay coherencia de mood aunque varíe la energía
|
||||
- [ ] El set cuenta una historia que tiene inicio, clímax y cierre
|
||||
469
AbletonMCP_AI/MCP_Server/role_matcher.py
Normal file
469
AbletonMCP_AI/MCP_Server/role_matcher.py
Normal 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),
|
||||
}
|
||||
308
AbletonMCP_AI/MCP_Server/sample_index.py
Normal file
308
AbletonMCP_AI/MCP_Server/sample_index.py
Normal 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)")
|
||||
1011
AbletonMCP_AI/MCP_Server/sample_manager.py
Normal file
1011
AbletonMCP_AI/MCP_Server/sample_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
2640
AbletonMCP_AI/MCP_Server/sample_selector.py
Normal file
2640
AbletonMCP_AI/MCP_Server/sample_selector.py
Normal file
File diff suppressed because it is too large
Load Diff
244
AbletonMCP_AI/MCP_Server/sample_system_demo.py
Normal file
244
AbletonMCP_AI/MCP_Server/sample_system_demo.py
Normal 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()
|
||||
16
AbletonMCP_AI/MCP_Server/scan_audio.py
Normal file
16
AbletonMCP_AI/MCP_Server/scan_audio.py
Normal 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)
|
||||
BIN
AbletonMCP_AI/MCP_Server/scan_log.txt
Normal file
BIN
AbletonMCP_AI/MCP_Server/scan_log.txt
Normal file
Binary file not shown.
198
AbletonMCP_AI/MCP_Server/segment_rag_builder.py
Normal file
198
AbletonMCP_AI/MCP_Server/segment_rag_builder.py
Normal 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())
|
||||
7032
AbletonMCP_AI/MCP_Server/server.py
Normal file
7032
AbletonMCP_AI/MCP_Server/server.py
Normal file
File diff suppressed because it is too large
Load Diff
1366
AbletonMCP_AI/MCP_Server/server_v2.py
Normal file
1366
AbletonMCP_AI/MCP_Server/server_v2.py
Normal file
File diff suppressed because it is too large
Load Diff
798
AbletonMCP_AI/MCP_Server/socket_smoke_test.py
Normal file
798
AbletonMCP_AI/MCP_Server/socket_smoke_test.py
Normal 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())
|
||||
6084
AbletonMCP_AI/MCP_Server/song_generator.py
Normal file
6084
AbletonMCP_AI/MCP_Server/song_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
16
AbletonMCP_AI/MCP_Server/start_server.py
Normal file
16
AbletonMCP_AI/MCP_Server/start_server.py
Normal 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)
|
||||
177
AbletonMCP_AI/MCP_Server/template_analyzer.py
Normal file
177
AbletonMCP_AI/MCP_Server/template_analyzer.py
Normal 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()
|
||||
82
AbletonMCP_AI/MCP_Server/tofix.md
Normal file
82
AbletonMCP_AI/MCP_Server/tofix.md
Normal 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` |
|
||||
163
AbletonMCP_AI/MCP_Server/vector_manager.py
Normal file
163
AbletonMCP_AI/MCP_Server/vector_manager.py
Normal 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]")
|
||||
Reference in New Issue
Block a user