Initial commit: AbletonMCP-AI complete system

- MCP Server with audio fallback, sample management
- Song generator with bus routing
- Reference listener and audio resampler
- Vector-based sample search
- Master chain with limiter and calibration
- Fix: Audio fallback now works without M4L
- Fix: Full song detection in sample loader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renato97
2026-03-28 22:53:10 -03:00
commit 6ec8663954
120 changed files with 59101 additions and 0 deletions

View File

@@ -0,0 +1,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)")