Initial commit: AbletonMCP-AI complete system

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

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

View File

@@ -0,0 +1,39 @@
# Abletunes Template Notes
Estos templates muestran patrones claros de produccion real que conviene copiar en el generador.
## Patrones fuertes
- Son `arrangement-first`, no `session-first`. En los cuatro sets los clips viven casi enteros en Arrangement y las scenes estan vacias o sin rol productivo.
- Todos usan locators para secciones (`Intro`, `Breakdown`, `Drop`, `Break`, `Outro`, `End`) y esas secciones casi siempre caen en bloques de `16`, `32`, `64`, `96` o `128` beats.
- Siempre hay jerarquia por grupos: drums/top drums, bass, instruments, vox, fx.
- Casi siempre existe un `SC Trigger` o pista equivalente dedicada al sidechain.
- Los drums no son una sola pista. Hay capas separadas para kick, clap, snare, hats, ride, perc, fills, crashes, risers y FX.
- Las partes armonicas tampoco son una sola pista. Aparecen capas distintas para bassline, reese/sub, chord, piano, string, pluck, lead y layers.
- Mezclan MIDI e audio de forma agresiva. Un productor no se queda solo con MIDI: imprime loops, resamples, freeze y audios procesados cuando hace falta.
- Hay bastante tratamiento por pista: `Eq8`, `Compressor2`, `Reverb`, `AutoFilter`, `PingPongDelay`, `GlueCompressor`, `MultibandDynamics`, `Limiter`, `Saturator`.
## Lo que mas importa para el MCP
- El generador no tiene que crear "un loop largo". Tiene que crear secciones con mutaciones claras entre una y otra.
- Cada seccion necesita variacion de densidad, no solo mute/unmute basico. Los templates meten fills, crashes, reverse FX, chants, top loops y capas extra solo en puntos de tension.
- El arreglo profesional usa mas pistas especializadas de las que hoy genera el MCP. La separacion por rol es parte del sonido.
- Hay que imprimir mas audio original derivado del propio proyecto: resamples, reverses, freezes y FX hechos a partir de material propio.
- Los returns son pocos pero concretos. No hace falta llenar de sends; hace falta `reverb`, `delay` y buses de grupo bien usados.
## Señales concretas vistas en el pack
- `Abletunes - Dope As F_ck`: `128 BPM`, 6 grupos, 2 returns, `Sylenth1` dominante, mucha automatizacion (`8121` eventos).
- `Abletunes - Freedom`: `126 BPM`, mezcla house mas simple, bateria muy separada, menos automatizacion, mucho `OriginalSimpler` + `Serum`.
- `Abletunes - Hideout`: set largo y cargado, `Massive` + `Sylenth1`, una bateria enorme y mucha automatizacion (`6470` eventos).
- `Abletunes - Nobody's Watching`: enfoque mas stock, usa `Operator`, `Simpler`, bastante audio vocal y FX impresos.
## Reglas que deberiamos incorporar
- Generar por defecto en Arrangement, con locators reales y secciones de 16/32 bars.
- Añadir `SC Trigger`, grupos y returns fijos desde el blueprint.
- Separar drums en mas roles: kick, clap main, clap layer, snare fill, hats, ride, perc main, perc FX, crash, reverse, riser.
- Separar armonia y hooks: sub, bassline, chord stab, piano/keys, string/pad, pluck, lead, accent synth.
- Crear eventos de transicion por seccion: uplifter, downlifter, reverse crash, vocal chop, tom fill.
- Imprimir audio derivado del material generado cuando una capa necesite mas impacto o textura.
- Meter automatizacion por seccion en filtros, sends, volumen de grupos y FX de transicion.

View File

@@ -0,0 +1,203 @@
# Sistema de Gestión de Samples - AbletonMCP-AI
Sistema completo de indexación, clasificación y selección inteligente de samples musicales.
## Componentes
### 1. `audio_analyzer.py` - Análisis de Audio
Detecta automáticamente características de archivos de audio:
- **BPM**: Detección de tempo mediante análisis de onset
- **Key**: Detección de tonalidad mediante cromagrama
- **Tipo**: Clasificación en kick, snare, bass, synth, etc.
- **Características espectrales**: Centroide, rolloff, RMS
**Uso básico:**
```python
from audio_analyzer import analyze_sample
result = analyze_sample("path/to/sample.wav")
print(f"BPM: {result['bpm']}, Key: {result['key']}")
print(f"Tipo: {result['sample_type']}")
```
**Backends:**
- `librosa`: Análisis completo (requiere instalación)
- `basic`: Análisis por nombre de archivo (sin dependencias)
### 2. `sample_manager.py` - Gestión de Librería
Gestor completo de la librería de samples:
- Indexación recursiva de directorios
- Clasificación automática por categorías
- Metadatos extensibles (tags, rating, géneros)
- Búsqueda avanzada con múltiples filtros
- Persistencia en JSON
**Categorías principales:**
- `drums`: kick, snare, clap, hat, perc, shaker, tom, cymbal
- `bass`: sub, bassline, acid
- `synths`: lead, pad, pluck, chord, fx
- `vocals`: vocal, speech, chant
- `loops`: drum_loop, bass_loop, synth_loop, full_loop
- `one_shots`: hit, noise
**Uso básico:**
```python
from sample_manager import SampleManager
# Inicializar
manager = SampleManager(r"C:\Users\ren\embeddings\all_tracks")
# Escanear
stats = manager.scan_directory(analyze_audio=True)
# Buscar
kicks = manager.search(sample_type="kick", key="Am", bpm=128)
house_samples = manager.search(genres=["house"], limit=10)
# Obtener pack completo
pack = manager.get_pack_for_genre("techno", key="F#m", bpm=130)
```
### 3. `sample_selector.py` - Selección Inteligente
Selección contextual basada en género, key y BPM:
- Perfiles de género predefinidos
- Matching armónico entre samples
- Generación de kits de batería coherentes
- Mapeo MIDI automático
**Géneros soportados:**
- Techno (industrial, minimal, acid)
- House (deep, classic, progressive)
- Tech-House
- Trance (progressive, psy)
- Drum & Bass (liquid, neuro)
- Ambient
**Uso básico:**
```python
from sample_selector import SampleSelector
selector = SampleSelector()
# Seleccionar para un género
group = selector.select_for_genre("techno", key="F#m", bpm=130)
# Acceder a elementos
group.drums.kick # Sample de kick
group.bass # Lista de bass samples
group.synths # Lista de synths
# Mapeo MIDI
mapping = selector.get_midi_mapping_for_kit(group.drums)
# Cambio de key armónico
new_key = selector.suggest_key_change("Am", "fifth_up") # Em
```
## Integración con MCP Server
El servidor MCP expone las siguientes herramientas:
### Gestión de Librería
- `scan_sample_library` - Escanear directorio de samples
- `get_sample_library_stats` - Estadísticas de la librería
### Búsqueda y Selección
- `advanced_search_samples` - Búsqueda con filtros múltiples
- `select_samples_for_genre` - Selección automática por género
- `get_drum_kit_mapping` - Kit de batería con mapeo MIDI
- `get_sample_pack_for_project` - Pack completo para proyecto
### Análisis y Compatibilidad
- `analyze_audio_file` - Analizar archivo de audio
- `find_compatible_samples` - Encontrar samples compatibles
- `suggest_key_change` - Sugerir cambios de tonalidad
## Estructura de Datos
### Sample
```python
@dataclass
class Sample:
id: str # ID único
name: str # Nombre del archivo
path: str # Ruta completa
category: str # Categoría principal
subcategory: str # Subcategoría
sample_type: str # Tipo específico
key: Optional[str] # Tonalidad (Am, F#m, C)
bpm: Optional[float] # BPM
duration: float # Duración en segundos
genres: List[str] # Géneros asociados
tags: List[str] # Tags
rating: int # Rating 0-5
```
### DrumKit
```python
@dataclass
class DrumKit:
name: str
kick: Optional[Sample]
snare: Optional[Sample]
clap: Optional[Sample]
hat_closed: Optional[Sample]
hat_open: Optional[Sample]
perc1: Optional[Sample]
perc2: Optional[Sample]
```
## Mapeo MIDI
Notas estándar para drums:
- `36` (C1): Kick
- `38` (D1): Snare
- `39` (D#1): Clap
- `42` (F#1): Closed Hat
- `46` (A#1): Open Hat
- `41` (F1): Tom Low
- `49` (C#2): Crash
## Ejemplos de Uso
### Crear un track completo
```python
# Seleccionar samples para techno
selector = get_selector()
group = selector.select_for_genre("techno", key="F#m", bpm=130)
# Usar con Ableton
ableton = get_ableton_connection()
# Crear tracks y cargar samples
for i, sample in enumerate([group.drums.kick, group.drums.snare]):
if sample:
print(f"Cargar {sample.name} en track {i}")
```
### Buscar samples compatibles
```python
# Encontrar samples que combinen con un kick
kick = manager.get_by_path("path/to/kick.wav")
compatible = selector.find_compatible_samples(kick, max_results=5)
for sample, score in compatible:
print(f"{sample.name}: {score:.1%} compatible")
```
## Archivos Generados
- `.sample_cache/sample_library.json` - Índice de la librería
- `.sample_cache/library_stats.json` - Estadísticas
## Dependencias Opcionales
Para análisis de audio completo:
```bash
pip install librosa soundfile numpy
```
Sin estas dependencias, el sistema funciona en modo "basic" usando metadatos de los nombres de archivo.

View File

@@ -0,0 +1,26 @@
"""
MCP Server para AbletonMCP-AI
Servidor FastMCP que conecta Claude con Ableton Live 12
"""
from .server import mcp, main
from .song_generator import SongGenerator
from .sample_index import SampleIndex
# Nuevo sistema de samples
try:
SAMPLE_SYSTEM_AVAILABLE = True
except ImportError:
SAMPLE_SYSTEM_AVAILABLE = False
__all__ = [
'mcp', 'main',
'SongGenerator', 'SampleIndex',
]
if SAMPLE_SYSTEM_AVAILABLE:
__all__.extend([
'SampleManager', 'Sample', 'get_manager',
'SampleSelector', 'get_selector', 'DrumKit', 'InstrumentGroup',
'AudioAnalyzer', 'analyze_sample', 'SampleType',
])

View File

@@ -0,0 +1,318 @@
import json
import socket
from datetime import datetime
import os
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent11_review_harmony.txt"
CHORD_TONES = {
"Am": [57, 60, 64],
"F": [53, 57, 60],
"C": [48, 52, 55],
"G": [43, 47, 50]
}
CHORD_NAMES = {
"Am": ["A", "C", "E"],
"F": ["F", "A", "C"],
"C": ["C", "E", "G"],
"G": ["G", "B", "D"]
}
AM_SCALE = [57, 59, 60, 62, 64, 65, 67]
PROGRESSION_ORDER = ["Am", "F", "C", "G"]
CHORD_DURATION = 8.0
def pitch_to_name(pitch):
names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
return names[pitch % 12]
def get_chord_at_time(start_time):
chord_index = int(start_time // CHORD_DURATION) % 4
return PROGRESSION_ORDER[chord_index]
def normalize_to_octave(pitch, target_octave=3):
return (pitch % 12) + (target_octave * 12)
class AbletonSocketClient:
def __init__(self, host="127.0.0.1", port=9877, timeout=15.0):
self.host = host
self.port = port
self.timeout = timeout
def send(self, command_type, params=None):
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
try:
line = reader.readline()
finally:
reader.close()
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def log_message(msg):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] {msg}\n"
print(log_line.strip())
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_line)
def analyze_track_harmony(client, track_index, track_name, scene_index=0):
issues = []
notes_in_key = 0
notes_out_of_key = 0
chord_matches = 0
chord_mismatches = 0
try:
response = client.send("get_notes", {
"track_index": track_index,
"scene_index": scene_index
})
if response.get("status") != "success":
return {"error": response.get("message", "Unknown error")}
notes = response.get("result", {}).get("notes", [])
if not notes:
return {"warning": "No notes found in clip"}
for note in notes:
pitch = note.get("pitch", 60)
start = note.get("start", 0)
duration = note.get("duration", 1)
pitch_class = pitch % 12
current_chord = get_chord_at_time(start)
in_am_scale = any((pitch % 12) == (p % 12) for p in AM_SCALE)
if in_am_scale:
notes_in_key += 1
else:
notes_out_of_key += 1
issues.append({
"type": "out_of_key",
"pitch": pitch,
"pitch_name": pitch_to_name(pitch),
"start": start,
"expected": "Am scale (A, B, C, D, E, F, G)"
})
chord_tones_normalized = [t % 12 for t in CHORD_TONES[current_chord]]
if pitch_class in chord_tones_normalized:
chord_matches += 1
else:
chord_mismatches += 1
chord_tone_names = CHORD_NAMES[current_chord]
issues.append({
"type": "chord_tone_mismatch",
"pitch": pitch,
"pitch_name": pitch_to_name(pitch),
"start": start,
"chord": current_chord,
"expected_chord_tones": chord_tone_names
})
return {
"total_notes": len(notes),
"notes_in_key": notes_in_key,
"notes_out_of_key": notes_out_of_key,
"chord_matches": chord_matches,
"chord_mismatches": chord_mismatches,
"issues": issues
}
except Exception as e:
return {"error": str(e)}
def analyze_bass_notes(client, track_index, scene_index=0):
issues = []
correct_roots = 0
incorrect_roots = 0
try:
response = client.send("get_notes", {
"track_index": track_index,
"scene_index": scene_index
})
if response.get("status") != "success":
return {"error": response.get("message", "Unknown error")}
notes = response.get("result", {}).get("notes", [])
if not notes:
return {"warning": "No bass notes found"}
ROOT_NOTES = {
"Am": 57,
"F": 53,
"C": 48,
"G": 43
}
for note in notes:
pitch = note.get("pitch", 60)
start = note.get("start", 0)
current_chord = get_chord_at_time(start)
expected_root = ROOT_NOTES[current_chord]
expected_root_class = expected_root % 12
pitch_class = pitch % 12
if pitch_class == expected_root_class:
correct_roots += 1
else:
incorrect_roots += 1
if start % 4.0 < 0.5:
issues.append({
"type": "wrong_bass_root",
"pitch": pitch,
"pitch_name": pitch_to_name(pitch),
"start": start,
"chord": current_chord,
"expected_root": pitch_to_name(expected_root)
})
return {
"total_notes": len(notes),
"correct_roots": correct_roots,
"incorrect_roots": incorrect_roots,
"issues": issues
}
except Exception as e:
return {"error": str(e)}
def main():
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
log_message("=" * 60)
log_message("AGENT 11 - HARMONIC COHERENCE REVIEW")
log_message("=" * 60)
log_message(f"Target progression: Am - F - C - G (8 beats each)")
log_message(f"Am scale: A, B, C, D, E, F, G")
log_message("")
client = AbletonSocketClient()
session = client.send("get_session_info")
if session.get("status") != "success":
log_message("ERROR: Cannot connect to Ableton session")
return
log_message(f"Session: {session.get('result', {}).get('num_tracks', 0)} tracks, "
f"tempo: {session.get('result', {}).get('tempo', 120)} BPM")
tracks_response = client.send("get_tracks")
if tracks_response.get("status") != "success":
log_message("ERROR: Cannot get tracks")
return
tracks = tracks_response.get("result", [])
midi_tracks = [
(i, t.get("name", "Unknown"), t.get("session_clip_count", 0))
for i, t in enumerate(tracks)
if t.get("has_midi_input") and t.get("session_clip_count", 0) > 0
]
log_message(f"Found {len(midi_tracks)} MIDI tracks with clips")
log_message("")
total_issues = 0
critical_issues = 0
for track_index, track_name, clip_count in midi_tracks:
log_message(f"\n--- TRACK {track_index}: {track_name} ---")
if "BASS" in track_name.upper():
log_message("Analyzing as BASS track (checking root notes)")
result = analyze_bass_notes(client, track_index)
else:
log_message("Analyzing harmonic content")
result = analyze_track_harmony(client, track_index, track_name)
if "error" in result:
log_message(f" ERROR: {result['error']}")
continue
if "warning" in result:
log_message(f" WARNING: {result['warning']}")
continue
if "total_notes" in result:
log_message(f" Total notes: {result['total_notes']}")
if "notes_in_key" in result:
log_message(f" Notes in Am scale: {result['notes_in_key']}/{result['total_notes']}")
if result["notes_out_of_key"] > 0:
log_message(f" OUT OF KEY: {result['notes_out_of_key']} notes")
total_issues += result["notes_out_of_key"]
if "chord_matches" in result:
log_message(f" Chord tone matches: {result['chord_matches']}/{result['total_notes']}")
if result["chord_mismatches"] > 0:
log_message(f" CHORD MISMATCHES: {result['chord_mismatches']} notes")
if "correct_roots" in result:
log_message(f" Correct bass roots: {result['correct_roots']}/{result['total_notes']}")
if result["incorrect_roots"] > 0:
log_message(f" WRONG BASS ROOTS: {result['incorrect_roots']} notes")
total_issues += result["incorrect_roots"]
critical_issues += result["incorrect_roots"]
if result.get("issues"):
for issue in result["issues"][:5]:
if issue["type"] == "out_of_key":
log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} "
f"not in Am scale")
elif issue["type"] == "chord_tone_mismatch":
log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} "
f"not in chord {issue['chord']} (expected: {issue['expected_chord_tones']})")
elif issue["type"] == "wrong_bass_root":
log_message(f" [CRITICAL] Bass note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} "
f"should be {issue['expected_root']} for chord {issue['chord']}")
log_message("\n" + "=" * 60)
log_message("HARMONIC COHERENCE SUMMARY")
log_message("=" * 60)
if critical_issues > 0:
log_message(f"STATUS: CRITICAL ISSUES FOUND")
log_message(f" - {critical_issues} critical bass root mismatches")
log_message(f" - {total_issues} total harmonic issues")
log_message("")
log_message("RECOMMENDATION: Review bass notes and chord tones")
elif total_issues > 0:
log_message(f"STATUS: MINOR ISSUES FOUND")
log_message(f" - {total_issues} notes out of Am scale")
log_message("")
log_message("RECOMMENDATION: May be intentional chromatic passing tones")
else:
log_message(f"STATUS: HARMONICALLY COHERENT")
log_message(f" - All notes in Am scale")
log_message(f" - Bass follows root progression A-F-C-G")
log_message(f" - Chord tones align with progression")
log_message("")
log_message("Agent 11 review complete.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,192 @@
"""
Agent 17 - Sample Loading Reviewer
Verifies audio tracks have samples loaded and loads samples if needed.
"""
import socket
import json
import os
import glob
from datetime import datetime
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent17_review_samples.txt"
SAMPLE_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks"
ORGANIZED_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples"
HOST = "127.0.0.1"
PORT = 9877
def log(message):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] {message}"
print(log_line)
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_line + "\n")
def send_command(command_type, params=None):
if params is None:
params = {}
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
try:
sock.connect((HOST, PORT))
request = {"type": command_type, "params": params}
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
if b"\n" in response:
break
return json.loads(response.decode("utf-8").strip())
finally:
sock.close()
def find_samples(query, sample_type=None):
samples = []
search_paths = [ORGANIZED_LIBRARY, SAMPLE_LIBRARY]
for search_path in search_paths:
if not os.path.exists(search_path):
continue
pattern = f"**/*{query}*.wav"
for filepath in glob.glob(os.path.join(search_path, pattern), recursive=True):
if sample_type:
type_dir = os.path.join(search_path, sample_type)
if type_dir.lower() in filepath.lower():
samples.append(filepath)
else:
samples.append(filepath)
return samples[:15]
def load_samples_to_track(track_index, track_name, sample_type, positions):
samples = find_samples(sample_type)
if not samples:
log(f" No samples found for type: {sample_type}")
return 0
clips_loaded = 0
for i, sample_path in enumerate(samples):
if clips_loaded >= 10:
break
position = positions[i] if i < len(positions) else positions[-1] + (i - len(positions) + 1) * 4
try:
result = send_command("create_arrangement_audio_pattern", {
"track_index": track_index,
"file_path": sample_path,
"positions": [position],
"name": f"{track_name} Clip {i+1}"
})
if result.get("status") == "success":
clips_loaded += 1
log(f" Loaded: {os.path.basename(sample_path)} at position {position}")
else:
log(f" Failed: {result.get('message', 'Unknown error')}")
except Exception as e:
log(f" Error loading sample: {e}")
return clips_loaded
def main():
log("=" * 60)
log("Agent 17 - Sample Loading Reviewer Started")
log("=" * 60)
log("\n[1] Connecting to Ableton socket...")
try:
session = send_command("get_session_info", {})
if session.get("status") != "success":
log(f"ERROR: Failed to get session info: {session}")
return
log(f"Connected. Tempo: {session.get('result', {}).get('tempo', 'unknown')} BPM")
except Exception as e:
log(f"ERROR: Cannot connect to Ableton: {e}")
return
log("\n[2] Getting track list...")
try:
tracks_response = send_command("get_tracks", {})
if tracks_response.get("status") != "success":
log(f"ERROR: Failed to get tracks: {tracks_response}")
return
tracks = tracks_response.get("result", [])
log(f"Found {len(tracks)} tracks")
except Exception as e:
log(f"ERROR: Cannot get tracks: {e}")
return
log("\n[3] Analyzing audio tracks...")
audio_tracks_needing_samples = []
for track in tracks:
track_name = track.get("name", "")
track_index = track.get("index", -1)
has_audio = track.get("has_audio_input", False) and track.get("has_audio_output", False)
has_midi = track.get("has_midi_input", False)
arr_clips = track.get("arrangement_clip_count", 0)
if has_audio and not has_midi:
if arr_clips < 10:
audio_tracks_needing_samples.append({
"index": track_index,
"name": track_name,
"clips": arr_clips
})
log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (NEEDS SAMPLES)")
else:
log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (OK)")
if not audio_tracks_needing_samples:
log("\n[4] All audio tracks have sufficient samples!")
return
log(f"\n[4] {len(audio_tracks_needing_samples)} tracks need samples. Loading...")
track_type_map = {
"KICK": "kick",
"SNARE": "snare",
"HATS": "hat",
"HAT": "hat",
"BASS": "bass",
"LEAD": "synth",
"PAD": "atmos",
"ARP": "synth",
"PERC": "percussion",
"VOCAL": "vocal",
"RISER": "riser",
"CRASH": "crash",
"DOWNLIFTER": "fx",
"AUDIO": "synth"
}
positions = [0, 8, 16, 24, 32, 40, 48, 56, 64, 72]
for track_info in audio_tracks_needing_samples:
track_index = track_info["index"]
track_name = track_info["name"]
sample_type = "synth"
for key, stype in track_type_map.items():
if key in track_name.upper():
sample_type = stype
break
log(f"\n Loading {sample_type} samples into track {track_index} ('{track_name}')...")
clips_loaded = load_samples_to_track(track_index, track_name, sample_type, positions)
track_info["loaded"] = clips_loaded
log("\n" + "=" * 60)
log("SUMMARY")
log("=" * 60)
for track_info in audio_tracks_needing_samples:
log(f" Track {track_info['index']} ('{track_info['name']}'): {track_info.get('clips', 0)} -> +{track_info.get('loaded', 0)} clips loaded")
log("\nAgent 17 completed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Agent 7 - VOCAL/CHOIR SPECIALIST
Loads vocal samples at specific arrangement positions
"""
import socket
import json
import sys
HOST = "127.0.0.1"
PORT = 9877
VOCAL_MAIN_TRACK = 12
VOCAL_TEXTURE_TRACK = 13
VOCAL_MAIN_SAMPLES = [
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\BBH- Primer Impacto - Vocal Quema D#m 126 Bpm.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 1.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 2.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 3.wav",
]
VOCAL_TEXTURE_SAMPLES = [
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_03_Am_125.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_05_Cm_125.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_08_Cm_125.wav",
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_10_Bm_125.wav",
]
VOCAL_MAIN_POSITIONS = [16.0, 48.0, 80.0, 112.0]
VOCAL_TEXTURE_POSITIONS = [0.0, 32.0, 64.0, 96.0]
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent7_vocals.txt"
def send_command(command_type: str, params: dict = None, timeout: float = 45.0) -> dict:
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((HOST, PORT), timeout=timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
line = reader.readline()
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def log(msg: str):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(msg + "\n")
print(msg)
def main():
log("=" * 60)
log("AGENT 7 - VOCAL/CHOIR SPECIALIST")
log("=" * 60)
# Step 1: Set input routing to "No Input" for both tracks
log("\n[STEP 1] Setting input routing to 'No Input'...")
for track_idx, track_name in [(VOCAL_MAIN_TRACK, "VOCAL MAIN"), (VOCAL_TEXTURE_TRACK, "VOCAL TEXTURE")]:
try:
result = send_command("set_track_input_routing", {"index": track_idx, "routing_name": "No Input"})
log(f" Track {track_idx} ({track_name}): {result}")
except Exception as e:
log(f" ERROR Track {track_idx}: {e}")
# Step 2: Load VOCAL MAIN samples at key moments
log("\n[STEP 2] Loading VOCAL MAIN samples at key moments...")
for i, (sample_path, position) in enumerate(zip(VOCAL_MAIN_SAMPLES, VOCAL_MAIN_POSITIONS)):
try:
result = send_command("create_arrangement_audio_pattern", {
"track_index": VOCAL_MAIN_TRACK,
"file_path": sample_path,
"positions": [position],
"name": f"Vocal Main {i+1}"
})
log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}")
except Exception as e:
log(f" ERROR at position {position}: {e}")
# Step 3: Load VOCAL TEXTURE samples at atmospheric positions
log("\n[STEP 3] Loading VOCAL TEXTURE samples at atmospheric positions...")
for i, (sample_path, position) in enumerate(zip(VOCAL_TEXTURE_SAMPLES, VOCAL_TEXTURE_POSITIONS)):
try:
result = send_command("create_arrangement_audio_pattern", {
"track_index": VOCAL_TEXTURE_TRACK,
"file_path": sample_path,
"positions": [position],
"name": f"Vocal Texture {i+1}"
})
log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}")
except Exception as e:
log(f" ERROR at position {position}: {e}")
log("\n" + "=" * 60)
log("AGENT 7 COMPLETE - Vocal layers loaded")
log("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,102 @@
import json
import socket
from datetime import datetime
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent8_fx.txt"
def log(msg):
timestamp = datetime.now().isoformat()
entry = f"[{timestamp}] {msg}"
print(entry)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(entry + "\n")
class AbletonSocketClient:
def __init__(self, host="127.0.0.1", port=9877, timeout=30.0):
self.host = host
self.port = port
self.timeout = timeout
def send(self, command_type, params=None):
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
try:
line = reader.readline()
finally:
reader.close()
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def main():
log("=" * 60)
log("AGENT 8 - FX TRANSITION SPECIALIST")
log("=" * 60)
client = AbletonSocketClient()
RISER_TRACK = 16
DOWNLIFTER_TRACK = 17
CRASH_TRACK = 18
RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto -Risers 2.wav"
DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\EFX_01_Em_125.wav"
CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto - Crash 1.wav"
RISER_POSITIONS = [14, 46, 78, 110, 142, 174]
DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176]
CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192]
log(f"Track indices: RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}")
log(f"Riser positions: {RISER_POSITIONS}")
log(f"Downlifter positions: {DOWNLIFTER_POSITIONS}")
log(f"Crash positions: {CRASH_POSITIONS}")
log("")
log("Step 1: Placing RISER samples...")
result = client.send("create_arrangement_audio_pattern", {
"track_index": RISER_TRACK,
"file_path": RISER_PATH,
"positions": RISER_POSITIONS,
"name": "RISER FX"
})
log(f"RISER result: {json.dumps(result, indent=2)}")
log("")
log("Step 2: Placing DOWNLIFTER samples (using EFX fallback)...")
result = client.send("create_arrangement_audio_pattern", {
"track_index": DOWNLIFTER_TRACK,
"file_path": DOWNLIFTER_PATH,
"positions": DOWNLIFTER_POSITIONS,
"name": "DOWNLIFTER FX"
})
log(f"DOWNLIFTER result: {json.dumps(result, indent=2)}")
log("")
log("Step 3: Placing CRASH samples...")
result = client.send("create_arrangement_audio_pattern", {
"track_index": CRASH_TRACK,
"file_path": CRASH_PATH,
"positions": CRASH_POSITIONS,
"name": "CRASH FX"
})
log(f"CRASH result: {json.dumps(result, indent=2)}")
log("")
log("=" * 60)
log("AGENT 8 COMPLETE")
log("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,184 @@
"""
Agent 9 - PERCUSSION SPECIALIST
Loads percussion samples into AUDIO PERC MAIN and AUDIO PERC FX tracks.
"""
import json
import socket
import os
from datetime import datetime
from typing import Any, Dict, List
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent9_perc.txt"
HOST = "127.0.0.1"
PORT = 9877
TIMEOUT = 30.0
PERC_MAIN_TRACK_INDEX = 14
PERC_FX_TRACK_INDEX = 15
PERC_MAIN_POSITIONS = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176]
PERC_FX_POSITIONS = [4, 12, 20, 28, 36, 44, 52, 60]
SAMPLE_BASE = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples"
PERC_LOOP_SAMPLES = [
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_01_Fm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_02_Any_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_03_A#_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_04_Any_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_05_Any_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_06_Dm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_07_Cm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_08_Fm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_09_Bm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_10_Dm_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_11_Am_125.wav"),
os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_12_Bm_125.wav"),
]
PERC_FX_SAMPLES = [
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 2.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 3.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 1.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 2.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 3.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 4.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 6.wav"),
os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 8.wav"),
]
def log(msg: str):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}"
print(line)
try:
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as e:
print(f"Log write error: {e}")
class AbletonSocketClient:
def __init__(self, host: str = HOST, port: int = PORT, timeout: float = TIMEOUT):
self.host = host
self.port = port
self.timeout = timeout
def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
try:
line = reader.readline()
finally:
reader.close()
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def set_input_routing(client: AbletonSocketClient, track_index: int, routing_name: str) -> bool:
try:
response = client.send("set_track_input_routing", {
"track_index": track_index,
"routing_name": routing_name,
})
if response.get("status") == "success":
log(f"Set track {track_index} input routing to '{routing_name}'")
return True
else:
log(f"Failed to set input routing: {response.get('message', 'unknown error')}")
return False
except Exception as e:
log(f"Error setting input routing: {e}")
return False
def load_audio_pattern(client: AbletonSocketClient, track_index: int, file_path: str, positions: List[float], name: str = "") -> bool:
if not os.path.exists(file_path):
log(f"Sample not found: {file_path}")
return False
try:
response = client.send("create_arrangement_audio_pattern", {
"track_index": track_index,
"file_path": file_path,
"positions": positions,
"name": name or os.path.basename(file_path),
})
if response.get("status") == "success":
log(f"Loaded '{os.path.basename(file_path)}' at positions {positions[:3]}... on track {track_index}")
return True
else:
log(f"Failed to load audio: {response.get('message', 'unknown error')}")
return False
except Exception as e:
log(f"Error loading audio: {e}")
return False
def main():
log("=" * 60)
log("AGENT 9 - PERCUSSION SPECIALIST STARTING")
log("=" * 60)
client = AbletonSocketClient()
log("Connecting to Ableton socket...")
try:
info = client.send("get_session_info", {})
if info.get("status") != "success":
log("Failed to get session info")
return
log(f"Connected. BPM: {info.get('result', {}).get('tempo', 'unknown')}")
except Exception as e:
log(f"Connection failed: {e}")
return
log("Setting input routing to 'No Input'...")
set_input_routing(client, PERC_MAIN_TRACK_INDEX, "No Input")
set_input_routing(client, PERC_FX_TRACK_INDEX, "No Input")
log("")
log("Loading PERC MAIN loops...")
main_loaded = 0
for i, pos in enumerate(PERC_MAIN_POSITIONS):
if i < len(PERC_LOOP_SAMPLES):
sample = PERC_LOOP_SAMPLES[i]
if load_audio_pattern(client, PERC_MAIN_TRACK_INDEX, sample, [float(pos)], f"PERC_LOOP_{i+1}"):
main_loaded += 1
log(f"PERC MAIN: {main_loaded}/{len(PERC_MAIN_POSITIONS)} samples loaded")
log("")
log("Loading PERC FX hits...")
fx_loaded = 0
for i, pos in enumerate(PERC_FX_POSITIONS):
if i < len(PERC_FX_SAMPLES):
sample = PERC_FX_SAMPLES[i]
if load_audio_pattern(client, PERC_FX_TRACK_INDEX, sample, [float(pos)], f"PERC_FX_{i+1}"):
fx_loaded += 1
log(f"PERC FX: {fx_loaded}/{len(PERC_FX_POSITIONS)} samples loaded")
log("")
log("=" * 60)
log(f"AGENT 9 COMPLETE: MAIN={main_loaded}, FX={fx_loaded}")
log("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,681 @@
"""
audio_analyzer.py - Análisis de audio para detección de Key y BPM
Proporciona análisis básico de archivos de audio para extraer:
- BPM (tempo) mediante detección de onset y autocorrelación
- Key (tonalidad) mediante análisis de cromagrama
- Características espectrales para clasificación
"""
import os
import logging
import numpy as np
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger("AudioAnalyzer")
# Constantes musicales
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
KEY_PROFILES = {
# Perfiles de Krumhansl-Schmuckler para detección de tonalidad
'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88],
'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
}
CIRCLE_OF_FIFTHS_MAJOR = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
CIRCLE_OF_FIFTHS_MINOR = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm']
class SampleType(Enum):
"""Tipos de samples musicales"""
KICK = "kick"
SNARE = "snare"
CLAP = "clap"
HAT_CLOSED = "hat_closed"
HAT_OPEN = "hat_open"
HAT = "hat"
PERC = "perc"
SHAKER = "shaker"
TOM = "tom"
CRASH = "crash"
RIDE = "ride"
BASS = "bass"
SYNTH = "synth"
PAD = "pad"
LEAD = "lead"
PLUCK = "pluck"
ARP = "arp"
CHORD = "chord"
STAB = "stab"
VOCAL = "vocal"
FX = "fx"
LOOP = "loop"
AMBIENCE = "ambience"
UNKNOWN = "unknown"
@dataclass
class AudioFeatures:
"""Características extraídas de un archivo de audio"""
bpm: Optional[float]
key: Optional[str]
key_confidence: float
duration: float
sample_rate: int
sample_type: SampleType
spectral_centroid: float
spectral_rolloff: float
zero_crossing_rate: float
rms_energy: float
is_harmonic: bool
is_percussive: bool
suggested_genres: List[str]
class AudioAnalyzer:
"""
Analizador de audio para samples musicales.
Soporta múltiples backends:
- librosa (recomendado, más preciso)
- basic (fallback sin dependencias externas, basado en nombre de archivo)
"""
def __init__(self, backend: str = "auto"):
"""
Inicializa el analizador de audio.
Args:
backend: 'librosa', 'basic', o 'auto' (detecta automáticamente)
"""
self.backend = backend
self._librosa_available = False
self._soundfile_available = False
if backend in ("auto", "librosa"):
self._check_librosa()
if self._librosa_available:
logger.info("Usando backend: librosa")
else:
logger.info("Usando backend: basic (análisis por nombre de archivo)")
def _check_librosa(self):
"""Verifica si librosa está disponible"""
try:
import librosa
import soundfile as sf
self._librosa_available = True
self._soundfile_available = True
self.librosa = librosa
self.sf = sf
except ImportError:
self._librosa_available = False
self._soundfile_available = False
def analyze(self, file_path: str) -> AudioFeatures:
"""
Analiza un archivo de audio y extrae características.
Args:
file_path: Ruta al archivo de audio
Returns:
AudioFeatures con los datos extraídos
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Archivo no encontrado: {file_path}")
# Intentar análisis con librosa si está disponible
if self._librosa_available:
try:
return self._analyze_with_librosa(file_path)
except Exception as e:
logger.warning(f"Error con librosa: {e}, usando análisis básico")
# Fallback a análisis básico
return self._analyze_basic(file_path)
def _analyze_with_librosa(self, file_path: str) -> AudioFeatures:
"""Análisis completo usando librosa"""
# Cargar audio
y, sr = self.librosa.load(file_path, sr=None, mono=True)
# Duración
duration = self.librosa.get_duration(y=y, sr=sr)
# Detectar BPM
tempo, _ = self.librosa.beat.beat_track(y=y, sr=sr)
bpm = float(tempo) if isinstance(tempo, (int, float, np.number)) else None
# Análisis espectral
spectral_centroids = self.librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_rolloffs = self.librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
zcr = self.librosa.feature.zero_crossing_rate(y)[0]
rms = self.librosa.feature.rms(y=y)[0]
# Detectar key
key, key_confidence = self._detect_key_librosa(y, sr)
# Clasificación percusivo vs armónico
is_percussive = self._is_percussive(y, sr)
is_harmonic = not is_percussive and duration > 1.0
# Determinar tipo de sample
sample_type = self._classify_sample_type(
file_path, is_percussive, is_harmonic, duration,
float(np.mean(spectral_centroids)), float(np.mean(rms))
)
# Sugerir géneros
suggested_genres = self._suggest_genres(sample_type, bpm, key)
return AudioFeatures(
bpm=bpm,
key=key,
key_confidence=key_confidence,
duration=duration,
sample_rate=sr,
sample_type=sample_type,
spectral_centroid=float(np.mean(spectral_centroids)),
spectral_rolloff=float(np.mean(spectral_rolloffs)),
zero_crossing_rate=float(np.mean(zcr)),
rms_energy=float(np.mean(rms)),
is_harmonic=is_harmonic,
is_percussive=is_percussive,
suggested_genres=suggested_genres
)
def _detect_key_librosa(self, y: np.ndarray, sr: int) -> Tuple[Optional[str], float]:
"""
Detecta la tonalidad usando cromagrama y correlación con perfiles.
"""
try:
# Calcular cromagrama
chroma = self.librosa.feature.chroma_stft(y=y, sr=sr)
chroma_avg = np.mean(chroma, axis=1)
# Normalizar
chroma_avg = chroma_avg / (np.sum(chroma_avg) + 1e-10)
best_key = None
best_score = -np.inf
best_mode = None
# Probar todas las tonalidades mayores y menores
for mode, profile in KEY_PROFILES.items():
for i in range(12):
# Rotar el perfil
rotated_profile = np.roll(profile, i)
# Correlación
score = np.corrcoef(chroma_avg, rotated_profile)[0, 1]
if score > best_score:
best_score = score
best_mode = mode
best_key = NOTE_NAMES[i]
# Formatear resultado
if best_key:
if best_mode == 'minor':
best_key = best_key + 'm'
confidence = max(0.0, min(1.0, (best_score + 1) / 2))
return best_key, confidence
except Exception as e:
logger.warning(f"Error detectando key: {e}")
return None, 0.0
def _is_percussive(self, y: np.ndarray, sr: int) -> bool:
"""
Determina si un sonido es principalmente percusivo.
"""
try:
# Separar componentes armónicos y percusivos
y_harmonic, y_percussive = self.librosa.effects.hpss(y)
# Calcular energía relativa
energy_harmonic = np.sum(y_harmonic ** 2)
energy_percussive = np.sum(y_percussive ** 2)
total_energy = energy_harmonic + energy_percussive
if total_energy > 0:
percussive_ratio = energy_percussive / total_energy
return percussive_ratio > 0.6
except Exception as e:
logger.warning(f"Error en separación HPSS: {e}")
# Fallback: usar duración como heurística
duration = len(y) / sr
return duration < 0.5
def _analyze_basic(self, file_path: str) -> AudioFeatures:
"""
Análisis básico sin dependencias externas.
Usa metadatos del archivo y nombre para inferir características.
"""
path = Path(file_path)
name = path.stem
# Extraer del nombre
bpm = self._extract_bpm_from_name(name)
key = self._extract_key_from_name(name)
# Estimar duración del archivo
duration = self._estimate_duration(file_path)
# Clasificar por nombre
sample_type = self._classify_by_name(name)
# Determinar características por tipo
is_percussive = sample_type in [
SampleType.KICK, SampleType.SNARE, SampleType.CLAP,
SampleType.HAT, SampleType.HAT_CLOSED, SampleType.HAT_OPEN,
SampleType.PERC, SampleType.SHAKER, SampleType.TOM,
SampleType.CRASH, SampleType.RIDE
]
is_harmonic = sample_type in [
SampleType.BASS, SampleType.SYNTH, SampleType.PAD,
SampleType.LEAD, SampleType.PLUCK, SampleType.CHORD,
SampleType.VOCAL
]
# Valores por defecto basados en tipo
spectral_centroid = 5000.0 if is_percussive else 1000.0
rms_energy = 0.5
suggested_genres = self._suggest_genres(sample_type, bpm, key)
return AudioFeatures(
bpm=bpm,
key=key,
key_confidence=0.7 if key else 0.0,
duration=duration,
sample_rate=44100,
sample_type=sample_type,
spectral_centroid=spectral_centroid,
spectral_rolloff=spectral_centroid * 2,
zero_crossing_rate=0.1 if is_harmonic else 0.3,
rms_energy=rms_energy,
is_harmonic=is_harmonic,
is_percussive=is_percussive,
suggested_genres=suggested_genres
)
def _estimate_duration(self, file_path: str) -> float:
"""Estima la duración del archivo de audio"""
try:
import wave
ext = Path(file_path).suffix.lower()
if ext == '.wav':
with wave.open(file_path, 'rb') as wav:
frames = wav.getnframes()
rate = wav.getframerate()
return frames / float(rate)
elif ext in ('.mp3', '.ogg', '.flac', '.aif', '.aiff', '.m4a'):
windows_duration = self._estimate_duration_with_windows_shell(file_path)
if windows_duration > 0:
return windows_duration
# Estimación por tamaño de archivo
size = os.path.getsize(file_path)
# Aproximación: ~176KB por segundo para CD quality stereo
return size / (176.4 * 1024)
except Exception as e:
logger.warning(f"Error estimando duración: {e}")
return 0.0
def _estimate_duration_with_windows_shell(self, file_path: str) -> float:
"""Obtiene la duración usando metadatos del shell de Windows cuando están disponibles."""
if os.name != 'nt':
return 0.0
safe_path = file_path.replace("'", "''")
powershell_command = (
f"$path = '{safe_path}'; "
"$shell = New-Object -ComObject Shell.Application; "
"$folder = $shell.Namespace((Split-Path $path)); "
"$file = $folder.ParseName((Split-Path $path -Leaf)); "
"$duration = $folder.GetDetailsOf($file, 27); "
"Write-Output $duration"
)
try:
result = subprocess.run(
f'powershell -NoProfile -Command "{powershell_command}"',
capture_output=True,
text=True,
timeout=5,
check=False,
shell=True,
)
value = (result.stdout or "").strip()
if not value:
return 0.0
parts = value.split(':')
if len(parts) == 3:
return (int(parts[0]) * 3600) + (int(parts[1]) * 60) + float(parts[2])
return 0.0
except Exception:
return 0.0
def _extract_bpm_from_name(self, name: str) -> Optional[float]:
"""Extrae BPM del nombre del archivo"""
import re
patterns = [
r'[_\s\-](\d{2,3})\s*BPM',
r'[_\s\-](\d{2,3})[_\s\-]',
r'(\d{2,3})bpm',
r'[_\s\-](\d{2,3})\s*(?:BPM|bpm)?\s*(?:\.wav|\.mp3|\.aif)',
]
for pattern in patterns:
match = re.search(pattern, name, re.IGNORECASE)
if match:
bpm = int(match.group(1))
if 60 <= bpm <= 200:
return float(bpm)
return None
def _extract_key_from_name(self, name: str) -> Optional[str]:
"""Extrae key del nombre del archivo"""
import re
patterns = [
r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]',
r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b',
r'Key\s+([A-G][#b]?(?:m|min|minor)?)',
r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]',
]
for pattern in patterns:
match = re.search(pattern, name, re.IGNORECASE)
if match:
key = match.group(1)
# Normalizar
key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#')
key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#')
# Detectar si es menor
is_minor = 'm' in key.lower() or 'min' in key.lower()
key = key.replace('min', '').replace('minor', '').replace('major', '')
key = key.rstrip('mM')
if is_minor:
key = key + 'm'
return key
return None
def _classify_sample_type(self, file_path: str, is_percussive: bool,
is_harmonic: bool, duration: float,
spectral_centroid: float, rms: float) -> SampleType:
"""Clasifica el tipo de sample basado en características"""
# Primero intentar por nombre
sample_type = self._classify_by_name(Path(file_path).stem)
if sample_type != SampleType.UNKNOWN:
return sample_type
# Clasificación por características de audio
if is_percussive:
if duration < 0.1:
if spectral_centroid < 2000:
return SampleType.KICK
elif spectral_centroid > 8000:
return SampleType.HAT_CLOSED
else:
return SampleType.SNARE
elif duration < 0.3:
return SampleType.CLAP
else:
return SampleType.PERC
elif is_harmonic:
if spectral_centroid < 500:
return SampleType.BASS
elif duration > 4.0:
return SampleType.PAD
else:
return SampleType.SYNTH
return SampleType.UNKNOWN
def _classify_by_name(self, name: str) -> SampleType:
"""Clasifica el tipo de sample basado en su nombre"""
name_lower = name.lower()
# Mapeo de palabras clave a tipos
keywords = {
SampleType.KICK: ['kick', 'bd', 'bass drum', 'kickdrum', 'kik'],
SampleType.SNARE: ['snare', 'snr', 'sd', 'rim'],
SampleType.CLAP: ['clap', 'clp', 'handclap'],
SampleType.HAT_CLOSED: ['closed hat', 'closedhat', 'chh', 'closed'],
SampleType.HAT_OPEN: ['open hat', 'openhat', 'ohh', 'open'],
SampleType.HAT: ['hat', 'hihat', 'hi-hat', 'hh'],
SampleType.PERC: ['perc', 'percussion', 'conga', 'bongo', 'timb'],
SampleType.SHAKER: ['shaker', 'shake', 'tamb'],
SampleType.TOM: ['tom', 'tomtom'],
SampleType.CRASH: ['crash', 'cymbal'],
SampleType.RIDE: ['ride'],
SampleType.BASS: ['bass', 'bassline', 'sub', '808', 'reese'],
SampleType.SYNTH: ['synth', 'lead', 'arp', 'sequence'],
SampleType.PAD: ['pad', 'atmosphere', 'dron'],
SampleType.PLUCK: ['pluck'],
SampleType.CHORD: ['chord', 'stab'],
SampleType.VOCAL: ['vocal', 'vox', 'voice', 'speech', 'talk'],
SampleType.FX: ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit', 'noise'],
SampleType.LOOP: ['loop', 'full', 'groove'],
}
for sample_type, words in keywords.items():
for word in words:
if word in name_lower:
return sample_type
return SampleType.UNKNOWN
def _suggest_genres(self, sample_type: SampleType, bpm: Optional[float],
key: Optional[str]) -> List[str]:
"""Sugiere géneros musicales apropiados para el sample"""
genres = []
if bpm:
if 118 <= bpm <= 128:
genres.extend(['house', 'tech-house', 'deep-house'])
elif 124 <= bpm <= 132:
genres.extend(['tech-house', 'techno'])
elif 132 <= bpm <= 142:
genres.extend(['techno', 'peak-time-techno'])
elif 142 <= bpm <= 150:
genres.extend(['trance', 'hard-techno'])
elif 160 <= bpm <= 180:
genres.extend(['drum-and-bass', 'neurofunk'])
elif bpm < 118:
genres.extend(['downtempo', 'ambient', 'lo-fi'])
# Por tipo de sample
if sample_type in [SampleType.KICK, SampleType.SNARE, SampleType.CLAP]:
if not genres:
genres = ['techno', 'house']
elif sample_type == SampleType.BASS:
if not genres:
genres = ['techno', 'house', 'bass-music']
elif sample_type in [SampleType.SYNTH, SampleType.PAD]:
if not genres:
genres = ['trance', 'progressive', 'ambient']
return genres if genres else ['electronic']
def get_compatible_key(self, key: str, shift: int = 0) -> str:
"""
Obtiene una key compatible usando el círculo de quintas.
Args:
key: Key original (ej: 'Am', 'F#m')
shift: Desplazamiento en el círculo (+1 = quinta arriba, -1 = quinta abajo)
Returns:
Key resultante
"""
is_minor = key.endswith('m')
root = key.rstrip('m')
if root not in NOTE_NAMES:
return key
circle = CIRCLE_OF_FIFTHS_MINOR if is_minor else CIRCLE_OF_FIFTHS_MAJOR
try:
idx = circle.index(key)
new_idx = (idx + shift) % 12
return circle[new_idx]
except ValueError:
return key
def calculate_key_compatibility(self, key1: str, key2: str) -> float:
"""
Calcula la compatibilidad entre dos keys (0-1).
Usa el círculo de quintas: keys cercanas son más compatibles.
"""
if key1 == key2:
return 1.0
# Normalizar
def normalize(k):
is_minor = k.endswith('m')
root = k.rstrip('m')
# Convertir bemoles a sostenidos
root = root.replace('Db', 'C#').replace('Eb', 'D#')
root = root.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#')
return root + ('m' if is_minor else '')
k1 = normalize(key1)
k2 = normalize(key2)
if k1 == k2:
return 1.0
# Verificar si son modos diferentes de la misma nota
if k1.rstrip('m') == k2.rstrip('m'):
return 0.8 # Mismo root, diferente modo
# Usar círculo de quintas
is_minor1 = k1.endswith('m')
is_minor2 = k2.endswith('m')
if is_minor1 != is_minor2:
return 0.3 # Diferente modo, baja compatibilidad
circle = CIRCLE_OF_FIFTHS_MINOR if is_minor1 else CIRCLE_OF_FIFTHS_MAJOR
try:
idx1 = circle.index(k1)
idx2 = circle.index(k2)
distance = min(abs(idx1 - idx2), 12 - abs(idx1 - idx2))
# Compatibilidad decrece con la distancia
compatibility = max(0.0, 1.0 - (distance * 0.2))
return compatibility
except ValueError:
return 0.0
# Instancia global
_analyzer: Optional[AudioAnalyzer] = None
def get_analyzer() -> AudioAnalyzer:
"""Obtiene la instancia global del analizador"""
global _analyzer
if _analyzer is None:
_analyzer = AudioAnalyzer()
return _analyzer
def analyze_sample(file_path: str) -> Dict[str, Any]:
"""
Función de conveniencia para analizar un sample.
Returns:
Diccionario con las características del sample
"""
analyzer = get_analyzer()
features = analyzer.analyze(file_path)
return {
'bpm': features.bpm,
'key': features.key,
'key_confidence': features.key_confidence,
'duration': features.duration,
'sample_rate': features.sample_rate,
'sample_type': features.sample_type.value,
'spectral_centroid': features.spectral_centroid,
'rms_energy': features.rms_energy,
'is_harmonic': features.is_harmonic,
'is_percussive': features.is_percussive,
'suggested_genres': features.suggested_genres,
}
def quick_analyze(file_path: str) -> Dict[str, Any]:
"""
Análisis rápido basado solo en el nombre del archivo.
No requiere dependencias externas.
"""
analyzer = AudioAnalyzer(backend="basic")
features = analyzer.analyze(file_path)
return {
'bpm': features.bpm,
'key': features.key,
'sample_type': features.sample_type.value,
'suggested_genres': features.suggested_genres,
}
# Testing
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO)
if len(sys.argv) < 2:
print("Uso: python audio_analyzer.py <archivo_de_audio>")
sys.exit(1)
file_path = sys.argv[1]
print(f"\nAnalizando: {file_path}")
print("=" * 50)
try:
result = analyze_sample(file_path)
print("\nResultados:")
print(f" BPM: {result['bpm'] or 'No detectado'}")
print(f" Key: {result['key'] or 'No detectado'} (confianza: {result['key_confidence']:.2f})")
print(f" Duración: {result['duration']:.2f}s")
print(f" Tipo: {result['sample_type']}")
print(f" Géneros sugeridos: {', '.join(result['suggested_genres'])}")
print(f" Es percusivo: {result['is_percussive']}")
print(f" Es armónico: {result['is_harmonic']}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
"""
Enhanced Device Automation for Timbral Movement Between Sections.
This module provides expanded device automation parameters for musical variation.
"""
# =============================================================================
# ENHANCED SECTION DEVICE AUTOMATION - More timbral color per section
# =============================================================================
# Automatizacion de devices en tracks individuales por rol - ENHANCED
SECTION_DEVICE_AUTOMATION = {
# BASS - Filtros, drive y compresion dinamica
'bass': {
'Saturator': {
'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8},
'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10},
},
'Auto Filter': {
'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0},
'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06},
'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28},
},
'Compressor': {
'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0},
'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2},
},
'Utility': {
'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0},
},
},
'sub_bass': {
'Saturator': {
'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2},
},
'Auto Filter': {
'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0},
'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04},
},
'Utility': {
'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0},
'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0},
},
},
# PAD - Filtros envolventes con width y reverb
'pad': {
'Auto Filter': {
'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0},
'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28},
'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24},
'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8},
},
'Utility': {
'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90},
},
'Saturator': {
'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7},
'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12},
},
},
# ATMOS - Filtros espaciales con movement
'atmos': {
'Auto Filter': {
'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0},
'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32},
'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38},
'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5},
},
'Utility': {
'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75},
},
},
# FX ELEMENTS
'reverse_fx': {
'Auto Filter': {
'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0},
'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28},
'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5},
},
'Saturator': {
'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0},
},
},
'riser': {
'Auto Filter': {
'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0},
'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20},
'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8},
},
'Echo': {
'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15},
'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28},
},
'Saturator': {
'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2},
},
},
'impact': {
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14},
'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2},
},
'Saturator': {
'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5},
},
},
'drone': {
'Auto Filter': {
'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0},
'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22},
'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20},
'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8},
},
'Saturator': {
'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7},
},
},
# HATS - Filtros de brillantez con resonance y saturacion
'hat_closed': {
'Auto Filter': {
'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0},
'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14},
'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30},
},
'Saturator': {
'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6},
},
},
'hat_open': {
'Auto Filter': {
'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0},
'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20},
'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35},
},
'Echo': {
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12},
},
},
'top_loop': {
'Auto Filter': {
'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0},
'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22},
'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28},
},
'Echo': {
'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10},
},
},
# SYNTHS
'chords': {
'Auto Filter': {
'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0},
'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18},
'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35},
},
'Echo': {
'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12},
'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28},
},
'Saturator': {
'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0},
},
'Utility': {
'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00},
},
},
'lead': {
'Saturator': {
'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2},
'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15},
},
'Echo': {
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10},
'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22},
},
'Auto Filter': {
'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0},
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12},
},
'Utility': {
'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95},
},
},
'stab': {
'Saturator': {
'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2},
'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20},
},
'Auto Filter': {
'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0},
'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12},
},
'Utility': {
'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92},
},
},
'pluck': {
'Echo': {
'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15},
'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32},
},
'Auto Filter': {
'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0},
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12},
},
'Saturator': {
'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9},
},
},
'arp': {
'Echo': {
'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18},
'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38},
},
'Auto Filter': {
'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0},
'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15},
},
'Saturator': {
'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7},
},
},
'counter': {
'Echo': {
'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12},
},
'Auto Filter': {
'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0},
'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14},
},
'Utility': {
'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80},
},
},
# VOCAL
'vocal': {
'Echo': {
'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14},
'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10},
'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8},
},
'Auto Filter': {
'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0},
'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12},
},
'Saturator': {
'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9},
},
},
# DRUMS - Sin automatizacion de devices (manejados por volumen/sends)
'kick': {},
'clap': {},
'snare_fill': {},
'perc': {},
'ride': {},
'tom_fill': {},
'crash': {},
'sc_trigger': {},
}
# =============================================================================
# ENHANCED BUS DEVICE AUTOMATION - More drive/compression per section
# =============================================================================
BUS_DEVICE_AUTOMATION = {
'drums': {
'Compressor': {
'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5},
'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4},
'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018},
},
'Saturator': {
'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9},
'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10},
},
'Limiter': {
'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18},
},
'Auto Filter': {
'Frequency': {'intro': 8500.0, 'build': 10000.0, 'drop': 14000.0, 'break': 6500.0, 'outro': 7500.0},
'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.05, 'break': 0.18, 'outro': 0.14},
},
},
'bass': {
'Saturator': {
'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2},
'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10},
},
'Compressor': {
'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5},
'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0},
'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022},
},
'Utility': {
'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0},
},
'Auto Filter': {
'Frequency': {'intro': 5000.0, 'build': 7000.0, 'drop': 10000.0, 'break': 4500.0, 'outro': 5200.0},
'Dry/Wet': {'intro': 0.05, 'build': 0.08, 'drop': 0.12, 'break': 0.10, 'outro': 0.06},
},
},
'music': {
'Compressor': {
'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5},
'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0},
'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028},
},
'Auto Filter': {
'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 14000.0, 'break': 6000.0, 'outro': 7500.0},
'Dry/Wet': {'intro': 0.08, 'build': 0.05, 'drop': 0.03, 'break': 0.12, 'outro': 0.10},
},
'Utility': {
'Stereo Width': {'intro': 1.05, 'build': 1.10, 'drop': 1.12, 'break': 1.18, 'outro': 1.08},
},
'Saturator': {
'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35},
'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06},
},
},
'vocal': {
'Echo': {
'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.05, 'break': 0.15, 'outro': 0.08},
'Feedback': {'intro': 0.25, 'build': 0.38, 'drop': 0.28, 'break': 0.45, 'outro': 0.30},
},
'Compressor': {
'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5},
'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.04, 'build': 0.08, 'drop': 0.03, 'break': 0.12, 'outro': 0.06},
'Decay Time': {'intro': 2.0, 'build': 2.8, 'drop': 1.5, 'break': 3.5, 'outro': 2.5},
},
'Auto Filter': {
'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 13000.0, 'break': 7200.0, 'outro': 8000.0},
'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.04, 'break': 0.14, 'outro': 0.08},
},
},
'fx': {
'Auto Filter': {
'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12000.0, 'break': 5500.0, 'outro': 6000.0},
'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.06, 'break': 0.18, 'outro': 0.14},
'Resonance': {'intro': 0.15, 'build': 0.22, 'drop': 0.12, 'break': 0.28, 'outro': 0.18},
},
'Hybrid Reverb': {
'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.10, 'break': 0.22, 'outro': 0.16},
'Decay Time': {'intro': 2.5, 'build': 3.2, 'drop': 2.0, 'break': 4.0, 'outro': 3.0},
},
'Limiter': {
'Gain': {'intro': -0.2, 'build': 0.0, 'drop': 0.2, 'break': -0.3, 'outro': -0.1},
},
'Saturator': {
'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 2.0, 'break': 0.8, 'outro': 0.6},
'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.10},
},
},
}
# =============================================================================
# ENHANCED MASTER Device Automation - Section Energy Response
# =============================================================================
MASTER_DEVICE_AUTOMATION = {
'Utility': {
'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06},
'Gain': {'intro': 0.6, 'build': 0.8, 'drop': 1.0, 'break': 0.5, 'outro': 0.5},
},
'Saturator': {
'Drive': {'intro': 0.2, 'build': 0.35, 'drop': 0.5, 'break': 0.15, 'outro': 0.18},
'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.08, 'outro': 0.12},
},
'Compressor': {
'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.70, 'break': 0.50, 'outro': 0.52},
'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -14.0, 'break': -8.0, 'outro': -9.0},
'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022},
'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.16},
},
'Limiter': {
'Gain': {'intro': 1.0, 'build': 1.2, 'drop': 1.4, 'break': 0.9, 'outro': 0.95},
'Ceiling': {'intro': -0.5, 'build': -0.8, 'drop': -1.0, 'break': -0.3, 'outro': -0.4},
},
'Auto Filter': {
'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 15000.0, 'break': 6000.0, 'outro': 7000.0},
'Dry/Wet': {'intro': 0.05, 'build': 0.03, 'drop': 0.02, 'break': 0.08, 'outro': 0.06},
},
'Echo': {
'Dry/Wet': {'intro': 0.02, 'build': 0.06, 'drop': 0.04, 'break': 0.08, 'outro': 0.04},
'Feedback': {'intro': 0.15, 'build': 0.28, 'drop': 0.20, 'break': 0.32, 'outro': 0.22},
},
}
# Safety clamps for device parameters to prevent extreme values
DEVICE_PARAMETER_SAFETY_CLAMPS = {
'Drive': {'min': 0.0, 'max': 6.0},
'Frequency': {'min': 20.0, 'max': 20000.0},
'Dry/Wet': {'min': 0.0, 'max': 1.0},
'Feedback': {'min': 0.0, 'max': 0.7},
'Stereo Width': {'min': 0.0, 'max': 1.3},
'Resonance': {'min': 0.0, 'max': 1.0},
'Ratio': {'min': 1.0, 'max': 20.0},
'Threshold': {'min': -60.0, 'max': 0.0},
'Attack': {'min': 0.0001, 'max': 0.5},
'Release': {'min': 0.001, 'max': 2.0},
'Gain': {'min': -1.0, 'max': 1.8},
'Decay Time': {'min': 0.1, 'max': 10.0},
}
MASTER_SAFETY_CLAMPS = {
'Stereo Width': {'min': 0.0, 'max': 1.25},
'Drive': {'min': 0.0, 'max': 1.5},
'Ratio': {'min': 0.45, 'max': 0.9},
'Gain': {'min': 0.0, 'max': 1.6},
'Attack': {'min': 0.0001, 'max': 0.1},
'Ceiling': {'min': -3.0, 'max': 0.0},
}

View File

@@ -0,0 +1,170 @@
import json
import socket
from datetime import datetime
LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\fx_group.txt"
def log(msg):
timestamp = datetime.now().isoformat()
entry = f"[{timestamp}] {msg}"
print(entry)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(entry + "\n")
class AbletonSocketClient:
def __init__(self, host="127.0.0.1", port=9877, timeout=30.0):
self.host = host
self.port = port
self.timeout = timeout
def send(self, command_type, params=None):
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
try:
line = reader.readline()
finally:
reader.close()
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def set_input_routing(client, track_index, routing_name):
result = client.send("set_track_input_routing", {
"index": track_index,
"routing_name": routing_name
})
return result
def main():
log("=" * 60)
log("FX GROUP - TRANSITION FX LOADER")
log("=" * 60)
client = AbletonSocketClient()
RISER_TRACK = 20
DOWNLIFTER_TRACK = 21
CRASH_TRACK = 22
IMPACT_TRACK = 23
NOISE_TRACK = 24
REVERSE_TRACK = 25
RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 1.wav"
DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\BBH - Primer Impacto -Downfilters 1.wav"
CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto - Crash 2.wav"
IMPACT_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto -Impact 1.wav"
NOISE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\EFX_01_Em_125.wav"
REVERSE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 4.wav"
RISER_POSITIONS = [14, 46, 78, 110, 142, 174]
DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176]
CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192]
IMPACT_POSITIONS = [16, 48, 80, 112, 144]
NOISE_POSITIONS = [14, 46, 78, 110, 142, 174]
REVERSE_POSITIONS = [14, 30, 62, 94, 126]
log(f"Track indices:")
log(f" RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}")
log(f" IMPACT={IMPACT_TRACK}, NOISE={NOISE_TRACK}, REVERSE={REVERSE_TRACK}")
log("")
log("Step 1: Placing RISER samples...")
log(f" Positions: {RISER_POSITIONS}")
log(f" File: {RISER_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": RISER_TRACK,
"file_path": RISER_PATH,
"positions": RISER_POSITIONS,
"name": "RISER FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("Step 2: Placing DOWNLIFTER samples...")
log(f" Positions: {DOWNLIFTER_POSITIONS}")
log(f" File: {DOWNLIFTER_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": DOWNLIFTER_TRACK,
"file_path": DOWNLIFTER_PATH,
"positions": DOWNLIFTER_POSITIONS,
"name": "DOWNLIFTER FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("Step 3: Placing CRASH samples...")
log(f" Positions: {CRASH_POSITIONS}")
log(f" File: {CRASH_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": CRASH_TRACK,
"file_path": CRASH_PATH,
"positions": CRASH_POSITIONS,
"name": "CRASH FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("Step 4: Placing IMPACT samples...")
log(f" Positions: {IMPACT_POSITIONS}")
log(f" File: {IMPACT_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": IMPACT_TRACK,
"file_path": IMPACT_PATH,
"positions": IMPACT_POSITIONS,
"name": "IMPACT FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("Step 5: Placing NOISE SWEEP samples...")
log(f" Positions: {NOISE_POSITIONS}")
log(f" File: {NOISE_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": NOISE_TRACK,
"file_path": NOISE_PATH,
"positions": NOISE_POSITIONS,
"name": "NOISE FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("Step 6: Placing REVERSE FX samples...")
log(f" Positions: {REVERSE_POSITIONS}")
log(f" File: {REVERSE_PATH}")
result = client.send("create_arrangement_audio_pattern", {
"track_index": REVERSE_TRACK,
"file_path": REVERSE_PATH,
"positions": REVERSE_POSITIONS,
"name": "REVERSE FX"
})
log(f" Result: {json.dumps(result, indent=2)}")
log("")
log("=" * 60)
log("Setting input routing to 'No Input' for all FX tracks...")
log("=" * 60)
for track_idx, track_name in [(RISER_TRACK, "RISER"), (DOWNLIFTER_TRACK, "DOWNLIFTER"),
(CRASH_TRACK, "CRASH"), (IMPACT_TRACK, "IMPACT"),
(NOISE_TRACK, "NOISE SWEEP"), (REVERSE_TRACK, "REVERSE FX")]:
result = set_input_routing(client, track_idx, "No Input")
log(f" {track_name} (track {track_idx}): {result}")
log("")
log("=" * 60)
log("FX GROUP COMPLETE")
log("=" * 60)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
"""
reference_stem_builder.py - Rebuild an Ableton arrangement directly from a reference track.
"""
from __future__ import annotations
import json
import logging
import socket
from pathlib import Path
from typing import Any, Dict, List, Tuple
import soundfile as sf
import torch
from demucs.apply import apply_model
from demucs.pretrained import get_model
try:
import librosa
except ImportError: # pragma: no cover
librosa = None
try:
from reference_listener import ReferenceAudioListener
except ImportError: # pragma: no cover
from .reference_listener import ReferenceAudioListener
logger = logging.getLogger("ReferenceStemBuilder")
HOST = "127.0.0.1"
PORT = 9877
MESSAGE_TERMINATOR = b"\n"
SCRIPT_DIR = Path(__file__).resolve().parent
PACKAGE_DIR = SCRIPT_DIR.parent
PROJECT_SAMPLES_DIR = PACKAGE_DIR.parent / "librerias" / "organized_samples"
SAMPLES_DIR = str(PROJECT_SAMPLES_DIR)
TRACK_LAYOUT = (
("REFERENCE FULL", 59, 0.72, True),
("REF DRUMS", 10, 0.84, False),
("REF BASS", 30, 0.82, False),
("REF OTHER", 50, 0.68, False),
("REF VOCALS", 40, 0.70, False),
)
SECTION_BLUEPRINTS = {
"club": [
("INTRO DJ", 16),
("GROOVE A", 16),
("VOCAL BUILD", 8),
("DROP A", 16),
("BREAKDOWN", 8),
("BUILD B", 8),
("DROP B", 16),
("PEAK", 8),
("OUTRO DJ", 16),
],
"standard": [
("INTRO", 8),
("BUILD", 8),
("DROP A", 16),
("BREAK", 8),
("DROP B", 16),
("OUTRO", 8),
],
}
class AbletonSocketClient:
def __init__(self, host: str = HOST, port: int = PORT):
self.host = host
self.port = port
def send(self, command_type: str, params: Dict[str, Any] | None = None, timeout: float = 30.0) -> Dict[str, Any]:
payload = json.dumps({"type": command_type, "params": params or {}}, separators=(",", ":")).encode("utf-8") + MESSAGE_TERMINATOR
with socket.create_connection((self.host, self.port), timeout=timeout) as sock:
sock.sendall(payload)
data = b""
while not data.endswith(MESSAGE_TERMINATOR):
chunk = sock.recv(65536)
if not chunk:
break
data += chunk
if not data:
raise RuntimeError(f"Sin respuesta para {command_type}")
return json.loads(data.decode("utf-8", errors="replace").strip())
def _resolve_reference_profile(reference_path: Path) -> Dict[str, Any]:
listener = ReferenceAudioListener(SAMPLES_DIR)
analysis = listener.analyze_reference(str(reference_path))
structure = "club" if analysis.get("duration", 0.0) >= 180 else "standard"
return {
"tempo": float(analysis.get("tempo", 128.0) or 128.0),
"key": str(analysis.get("key", "") or ""),
"duration": float(analysis.get("duration", 0.0) or 0.0),
"structure": structure,
"listener_device": analysis.get("device", "cpu"),
}
def ensure_reference_wav(reference_path: Path) -> Path:
if reference_path.suffix.lower() == ".wav":
return reference_path
if librosa is None:
raise RuntimeError("librosa no está disponible para convertir la referencia a WAV")
wav_path = reference_path.with_suffix(".wav")
if wav_path.exists() and wav_path.stat().st_size > 0:
return wav_path
y, sr = librosa.load(str(reference_path), sr=44100, mono=False)
if y.ndim == 1:
y = y.reshape(1, -1)
sf.write(str(wav_path), y.T, sr, subtype="PCM_16")
return wav_path
def separate_stems(reference_wav: Path, output_dir: Path) -> Dict[str, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
stem_root = output_dir / reference_wav.stem
expected = {
"reference": reference_wav,
"drums": stem_root / "drums.wav",
"bass": stem_root / "bass.wav",
"other": stem_root / "other.wav",
"vocals": stem_root / "vocals.wav",
}
if all(path.exists() and path.stat().st_size > 0 for path in expected.values()):
return expected
audio, sr = sf.read(str(reference_wav), always_2d=True)
if sr != 44100:
raise RuntimeError(f"Sample rate inesperado en referencia WAV: {sr}")
model = get_model("htdemucs")
model.cpu()
model.eval()
waveform = torch.tensor(audio.T, dtype=torch.float32)
separated = apply_model(model, waveform[None], device="cpu", progress=False)[0]
stem_root.mkdir(parents=True, exist_ok=True)
for stem_name, tensor in zip(model.sources, separated):
stem_path = stem_root / f"{stem_name}.wav"
sf.write(str(stem_path), tensor.detach().cpu().numpy().T, sr, subtype="PCM_16")
return expected
def _sections_for_structure(structure: str) -> List[Tuple[str, int]]:
return list(SECTION_BLUEPRINTS.get(structure.lower(), SECTION_BLUEPRINTS["standard"]))
def _create_track(client: AbletonSocketClient, name: str, color: int, volume: float) -> int:
response = client.send("create_track", {"type": "audio", "index": -1})
if response.get("status") != "success":
raise RuntimeError(response.get("message", f"No se pudo crear {name}"))
track_index = int(response.get("result", {}).get("index"))
client.send("set_track_name", {"index": track_index, "name": name})
client.send("set_track_color", {"index": track_index, "color": color})
client.send("set_track_volume", {"index": track_index, "volume": volume})
return track_index
def _import_full_length_audio(client: AbletonSocketClient, track_index: int, file_path: Path, name: str) -> None:
response = client.send("create_arrangement_audio_pattern", {
"track_index": track_index,
"file_path": str(file_path),
"positions": [0.0],
"name": name,
}, timeout=120.0)
if response.get("status") != "success":
raise RuntimeError(response.get("message", f"No se pudo importar {name}"))
def _prepare_navigation_scenes(client: AbletonSocketClient, structure: str) -> None:
sections = _sections_for_structure(structure)
session_info = client.send("get_session_info")
if session_info.get("status") != "success":
return
scene_count = int(session_info.get("result", {}).get("num_scenes", 0) or 0)
target_count = len(sections)
while scene_count < target_count:
create_response = client.send("create_scene", {"index": -1})
if create_response.get("status") != "success":
break
scene_count += 1
while scene_count > target_count and scene_count > 1:
delete_response = client.send("delete_scene", {"index": scene_count - 1})
if delete_response.get("status") != "success":
break
scene_count -= 1
for scene_index, (section_name, _) in enumerate(sections):
client.send("set_scene_name", {"index": scene_index, "name": section_name})
def rebuild_project_from_reference(reference_path: Path) -> Dict[str, Any]:
reference_path = reference_path.resolve()
if not reference_path.exists():
raise FileNotFoundError(reference_path)
profile = _resolve_reference_profile(reference_path)
reference_wav = ensure_reference_wav(reference_path)
stems = separate_stems(reference_wav, reference_path.parent / "stems")
client = AbletonSocketClient()
clear_response = client.send("clear_project", {"keep_tracks": 0}, timeout=120.0)
if clear_response.get("status") != "success":
raise RuntimeError(clear_response.get("message", "No se pudo limpiar el proyecto"))
client.send("stop", {})
client.send("set_tempo", {"tempo": round(profile["tempo"], 3)})
client.send("show_arrangement_view", {})
client.send("jump_to", {"time": 0})
created = []
for (track_name, color, volume, muted), stem_key in zip(TRACK_LAYOUT, ("reference", "drums", "bass", "other", "vocals")):
track_index = _create_track(client, track_name, color, volume)
_import_full_length_audio(client, track_index, stems[stem_key], track_name)
if muted:
client.send("set_track_mute", {"index": track_index, "mute": True})
created.append({
"track_index": track_index,
"name": track_name,
"file_path": str(stems[stem_key]),
})
_prepare_navigation_scenes(client, profile["structure"])
client.send("loop_selection", {"start": 0, "length": max(32.0, round(profile["duration"] * profile["tempo"] / 60.0, 3)), "enable": False})
client.send("jump_to", {"time": 0})
client.send("show_arrangement_view", {})
session_info = client.send("get_session_info")
return {
"reference": str(reference_path),
"tempo": profile["tempo"],
"key": profile["key"],
"structure": profile["structure"],
"listener_device": profile["listener_device"],
"stems": created,
"session_info": session_info.get("result", {}),
}
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description="Rebuild an Ableton project directly from a reference track.")
parser.add_argument("reference_path", help="Absolute or relative path to the reference audio file")
args = parser.parse_args()
result = rebuild_project_from_reference(Path(args.reference_path))
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,13 @@
# Dependencias de AbletonMCP-AI Server
# Instalar con: pip install -r requirements.txt
mcp>=1.0.0
# Servidor MCP FastMCP
# Opcional: para análisis de audio avanzado
# numpy>=1.24.0
# librosa>=0.10.0
# Opcional: para procesamiento con GPU AMD
# torch==2.4.1
# torch-directml>=0.2.5

View File

@@ -0,0 +1,525 @@
"""
retrieval_benchmark.py - Offline benchmark harness for retrieval quality inspection.
Analyzes reference tracks and outputs top-N candidates per role to help spot
role contamination and evaluate retrieval quality.
Usage:
python retrieval_benchmark.py --reference "path/to/track.mp3"
python retrieval_benchmark.py --reference "track1.mp3" "track2.mp3" --top-n 10
python retrieval_benchmark.py --reference "track.mp3" --output results.json --format json
python retrieval_benchmark.py --reference "track.mp3" --output results.md --format markdown
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional
# Add parent directory to path for imports when running as script
sys.path.insert(0, str(Path(__file__).parent))
from reference_listener import ReferenceAudioListener, ROLE_SEGMENT_SETTINGS
logger = logging.getLogger(__name__)
def _default_library_dir() -> Path:
"""Get the default library directory."""
return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks"
def run_benchmark(
reference_paths: List[str],
library_dir: Path,
top_n: int = 10,
roles: Optional[List[str]] = None,
duration_limit: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run retrieval benchmark on one or more reference tracks.
Args:
reference_paths: List of paths to reference audio files
library_dir: Path to the sample library
top_n: Number of top candidates to show per role
roles: Optional list of specific roles to analyze
duration_limit: Optional duration limit for analysis
Returns:
Dict containing benchmark results for each reference
"""
listener = ReferenceAudioListener(str(library_dir))
all_roles = list(ROLE_SEGMENT_SETTINGS.keys())
target_roles = [r for r in (roles or all_roles) if r in all_roles]
results = {
"benchmark_info": {
"library_dir": str(library_dir),
"top_n": top_n,
"roles": target_roles,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"device": listener.device_name,
},
"references": [],
}
for ref_path in reference_paths:
ref_path = Path(ref_path)
if not ref_path.exists():
logger.warning("Reference file not found: %s", ref_path)
continue
logger.info("Analyzing reference: %s", ref_path.name)
try:
start_time = time.time()
# Run match_assets to get candidates per role
match_result = listener.match_assets(str(ref_path))
reference_info = match_result.get("reference", {})
matches = match_result.get("matches", {})
elapsed = time.time() - start_time
ref_result = {
"file_name": ref_path.name,
"path": str(ref_path),
"analysis_time_seconds": round(elapsed, 2),
"reference_info": {
"tempo": reference_info.get("tempo"),
"key": reference_info.get("key"),
"duration": reference_info.get("duration"),
"rms_mean": reference_info.get("rms_mean"),
"onset_mean": reference_info.get("onset_mean"),
"spectral_centroid": reference_info.get("spectral_centroid"),
},
"sections": [
{
"kind": s.get("kind"),
"start": s.get("start"),
"end": s.get("end"),
"bars": s.get("bars"),
}
for s in match_result.get("reference_sections", [])
],
"role_candidates": {},
}
# Process each role
for role in target_roles:
role_matches = matches.get(role, [])
top_candidates = role_matches[:top_n]
ref_result["role_candidates"][role] = {
"total_available": len(role_matches),
"top_candidates": [
{
"rank": i + 1,
"file_name": c.get("file_name"),
"path": c.get("path"),
"score": c.get("score"),
"cosine": c.get("cosine"),
"segment_score": c.get("segment_score"),
"catalog_score": c.get("catalog_score"),
"tempo": c.get("tempo"),
"key": c.get("key"),
"duration": c.get("duration"),
}
for i, c in enumerate(top_candidates)
],
}
results["references"].append(ref_result)
logger.info("Completed analysis in %.2fs", elapsed)
except Exception as e:
logger.error("Failed to analyze %s: %s", ref_path, e, exc_info=True)
results["references"].append({
"file_name": ref_path.name,
"path": str(ref_path),
"error": str(e),
})
return results
def analyze_role_contamination(results: Dict[str, Any]) -> Dict[str, Any]:
"""
Analyze results for potential role contamination issues.
Returns a dict with contamination analysis:
- files appearing in multiple roles
- misnamed files (e.g., "bass" appearing in "kick" role)
- score distribution anomalies
"""
contamination = {
"cross_role_files": [],
"potential_mismatches": [],
"role_score_stats": {},
}
# Track files appearing in multiple roles
file_to_roles: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for ref in results.get("references", []):
ref_name = ref.get("file_name", "unknown")
for role, role_data in ref.get("role_candidates", {}).items():
for candidate in role_data.get("top_candidates", []):
file_name = candidate.get("file_name", "")
if file_name:
file_to_roles[file_name].append({
"reference": ref_name,
"role": role,
"rank": candidate.get("rank"),
"score": candidate.get("score"),
})
# Find files appearing in multiple roles
for file_name, appearances in file_to_roles.items():
unique_roles = set(a["role"] for a in appearances)
if len(unique_roles) > 1:
contamination["cross_role_files"].append({
"file_name": file_name,
"roles": list(unique_roles),
"appearances": appearances,
})
# Check for potential mismatches (filename suggests different role)
role_keywords = {
"kick": ["kick"],
"snare": ["snare", "clap"],
"hat": ["hat", "hihat", "hi-hat"],
"bass_loop": ["bass", "sub", "808"],
"perc_loop": ["perc", "percussion", "conga", "bongo"],
"top_loop": ["top", "drum loop", "full drum"],
"synth_loop": ["synth", "lead", "pad", "chord", "arp"],
"vocal_loop": ["vocal", "vox", "acapella"],
"crash_fx": ["crash", "cymbal", "impact"],
"fill_fx": ["fill", "transition", "tom"],
"snare_roll": ["roll", "snareroll"],
"atmos_fx": ["atmos", "drone", "ambient", "texture"],
"vocal_shot": ["shot", "vocal shot", "chop"],
}
for ref in results.get("references", []):
for role, role_data in ref.get("role_candidates", {}).items():
for candidate in role_data.get("top_candidates", []):
file_name = candidate.get("file_name", "").lower()
if not file_name:
continue
# Check if file name suggests a different role
expected_keywords = role_keywords.get(role, [])
other_role_matches = []
for other_role, keywords in role_keywords.items():
if other_role == role:
continue
if any(kw in file_name for kw in keywords):
other_role_matches.append(other_role)
if other_role_matches and expected_keywords:
# File name matches another role but not this one
if not any(kw in file_name for kw in expected_keywords):
contamination["potential_mismatches"].append({
"file_name": candidate.get("file_name"),
"assigned_role": role,
"rank": candidate.get("rank"),
"score": candidate.get("score"),
"suggested_roles": other_role_matches,
})
# Calculate score distribution per role
for ref in results.get("references", []):
for role, role_data in ref.get("role_candidates", {}).items():
scores = [
c.get("score", 0)
for c in role_data.get("top_candidates", [])
if c.get("score") is not None
]
if scores:
contamination["role_score_stats"][role] = {
"min": round(min(scores), 4),
"max": round(max(scores), 4),
"avg": round(sum(scores) / len(scores), 4),
"count": len(scores),
}
return contamination
def format_output_json(results: Dict[str, Any]) -> str:
"""Format results as JSON string."""
return json.dumps(results, indent=2, ensure_ascii=False)
def format_output_markdown(results: Dict[str, Any]) -> str:
"""Format results as markdown string."""
lines = []
# Header
lines.append("# Retrieval Benchmark Report")
lines.append("")
lines.append(f"**Generated:** {results['benchmark_info']['timestamp']}")
lines.append(f"**Library:** `{results['benchmark_info']['library_dir']}`")
lines.append(f"**Top N:** {results['benchmark_info']['top_n']}")
lines.append(f"**Device:** {results['benchmark_info']['device']}")
lines.append("")
# Process each reference
for ref in results.get("references", []):
lines.append(f"## Reference: {ref.get('file_name', 'unknown')}")
lines.append("")
# Error case
if "error" in ref:
lines.append(f"**Error:** {ref['error']}")
lines.append("")
continue
# Reference info
ref_info = ref.get("reference_info", {})
lines.append("### Reference Analysis")
lines.append("")
lines.append("| Property | Value |")
lines.append("|----------|-------|")
lines.append(f"| Tempo | {ref_info.get('tempo', 'N/A')} BPM |")
lines.append(f"| Key | {ref_info.get('key', 'N/A')} |")
lines.append(f"| Duration | {ref_info.get('duration', 'N/A')}s |")
lines.append(f"| RMS Mean | {ref_info.get('rms_mean', 'N/A')} |")
lines.append(f"| Onset Mean | {ref_info.get('onset_mean', 'N/A')} |")
lines.append(f"| Spectral Centroid | {ref_info.get('spectral_centroid', 'N/A')} Hz |")
lines.append("")
# Sections
sections = ref.get("sections", [])
if sections:
lines.append("### Detected Sections")
lines.append("")
lines.append("| Type | Start | End | Bars |")
lines.append("|------|-------|-----|------|")
for s in sections:
lines.append(f"| {s.get('kind', 'N/A')} | {s.get('start', 'N/A')}s | {s.get('end', 'N/A')}s | {s.get('bars', 'N/A')} |")
lines.append("")
# Role candidates
lines.append("### Top Candidates per Role")
lines.append("")
for role, role_data in ref.get("role_candidates", {}).items():
total = role_data.get("total_available", 0)
lines.append(f"#### {role} ({total} available)")
lines.append("")
candidates = role_data.get("top_candidates", [])
if not candidates:
lines.append("*No candidates found*")
lines.append("")
continue
lines.append("| Rank | File | Score | Cosine | Seg | Catalog | Tempo | Key | Duration |")
lines.append("|------|------|-------|--------|-----|---------|-------|-----|----------|")
for c in candidates:
lines.append(
f"| {c.get('rank', 'N/A')} | "
f"`{c.get('file_name', 'N/A')[:40]}` | "
f"{c.get('score', 0):.4f} | "
f"{c.get('cosine', 0):.4f} | "
f"{c.get('segment_score', 0):.4f} | "
f"{c.get('catalog_score', 0):.4f} | "
f"{c.get('tempo', 'N/A')} | "
f"{c.get('key', 'N/A')} | "
f"{c.get('duration', 'N/A'):.2f}s |"
)
lines.append("")
# Contamination analysis
if "contamination_analysis" in results:
contam = results["contamination_analysis"]
lines.append("## Role Contamination Analysis")
lines.append("")
# Cross-role files
cross_role = contam.get("cross_role_files", [])
if cross_role:
lines.append("### Files Appearing in Multiple Roles")
lines.append("")
for item in cross_role:
lines.append(f"- **{item['file_name']}**")
lines.append(f" - Roles: {', '.join(item['roles'])}")
for app in item["appearances"]:
lines.append(f" - {app['role']}: rank {app['rank']}, score {app['score']:.4f}")
lines.append("")
# Potential mismatches
mismatches = contam.get("potential_mismatches", [])
if mismatches:
lines.append("### Potential Role Mismatches")
lines.append("")
lines.append("Files whose names suggest a different role than assigned:")
lines.append("")
for item in mismatches:
lines.append(f"- **{item['file_name']}**")
lines.append(f" - Assigned: {item['assigned_role']} (rank {item['rank']}, score {item['score']:.4f})")
lines.append(f" - Suggested: {', '.join(item['suggested_roles'])}")
lines.append("")
# Score stats
score_stats = contam.get("role_score_stats", {})
if score_stats:
lines.append("### Score Distribution per Role")
lines.append("")
lines.append("| Role | Min | Max | Avg | Count |")
lines.append("|------|-----|-----|-----|-------|")
for role, stats in sorted(score_stats.items()):
lines.append(
f"| {role} | {stats['min']:.4f} | {stats['max']:.4f} | "
f"{stats['avg']:.4f} | {stats['count']} |"
)
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Offline benchmark harness for retrieval quality inspection.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --reference "track.mp3"
%(prog)s --reference "track1.mp3" "track2.mp3" --top-n 15
%(prog)s --reference "track.mp3" --output results.md --format markdown
%(prog)s --reference "track.mp3" --roles kick snare hat --top-n 20
""",
)
parser.add_argument(
"--reference", "-r",
nargs="+",
required=True,
help="One or more reference audio files to analyze",
)
parser.add_argument(
"--library-dir",
default=str(_default_library_dir()),
help="Audio library directory (default: ../librerias/all_tracks)",
)
parser.add_argument(
"--top-n", "-n",
type=int,
default=10,
help="Number of top candidates to show per role (default: 10)",
)
parser.add_argument(
"--roles",
nargs="*",
default=None,
help="Specific roles to analyze (default: all roles)",
)
parser.add_argument(
"--output", "-o",
type=str,
default=None,
help="Output file path for results",
)
parser.add_argument(
"--format", "-f",
choices=["json", "markdown", "md"],
default=None,
help="Output format (json or markdown). Auto-detected from output file extension if not specified.",
)
parser.add_argument(
"--analyze-contamination",
action="store_true",
help="Include role contamination analysis in output",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging",
)
parser.add_argument(
"--duration-limit",
type=float,
default=None,
help="Optional duration limit for audio analysis",
)
args = parser.parse_args()
# Configure logging
if args.verbose:
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
else:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
# Validate reference files
reference_paths = []
for ref in args.reference:
ref_path = Path(ref)
if ref_path.exists():
reference_paths.append(str(ref_path))
else:
logger.warning("Reference file not found: %s", ref)
if not reference_paths:
logger.error("No valid reference files provided")
return 1
# Run benchmark
logger.info("Running retrieval benchmark on %d reference(s)", len(reference_paths))
results = run_benchmark(
reference_paths=reference_paths,
library_dir=Path(args.library_dir),
top_n=args.top_n,
roles=args.roles,
duration_limit=args.duration_limit,
)
# Add contamination analysis if requested
if args.analyze_contamination:
logger.info("Analyzing role contamination...")
results["contamination_analysis"] = analyze_role_contamination(results)
# Determine output format
output_format = args.format
if output_format is None and args.output:
output_format = "markdown" if args.output.endswith(".md") else "json"
output_format = output_format or "text"
# Format output
if output_format in ("markdown", "md"):
output_text = format_output_markdown(results)
elif output_format == "json":
output_text = format_output_json(results)
else:
# Plain text summary
output_text = format_output_markdown(results)
# Write to file or stdout
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output_text, encoding="utf-8")
logger.info("Results written to: %s", output_path)
else:
print(output_text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,469 @@
"""
role_matcher.py - Phase 4: Role validation and sample matching utilities
This module provides enhanced role matching for sample selection with:
- Role validation based on audio characteristics
- Aggressive sample detection and filtering
- Logging of matching decisions
- Integration with reference_listener and sample_selector
"""
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger("RoleMatcher")
# ============================================================================
# CONSTANTS
# ============================================================================
# Valid roles for sample matching with their expected characteristics
VALID_ROLES = {
# One-shot drums
"kick": {"max_duration": 2.0, "min_onset": 0.3, "is_loop": False, "bus": "drums"},
"snare": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"},
"hat": {"max_duration": 1.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"},
"clap": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"},
"ride": {"max_duration": 3.0, "min_onset": 0.15, "is_loop": False, "bus": "drums"},
"perc": {"max_duration": 2.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"},
# Loops
"bass_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "bass"},
"perc_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"},
"top_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"},
"synth_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "music"},
"vocal_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "vocal"},
# FX
"crash_fx": {"max_duration": 4.0, "is_loop": False, "bus": "fx"},
"fill_fx": {"max_duration": 8.0, "is_loop": False, "bus": "fx"},
"snare_roll": {"max_duration": 8.0, "is_loop": False, "bus": "drums"},
"atmos_fx": {"min_duration": 4.0, "is_loop": True, "bus": "fx"},
"vocal_shot": {"max_duration": 3.0, "is_loop": False, "bus": "vocal"},
# Resample layers
"resample_reverse": {"is_loop": False, "bus": "fx"},
"resample_riser": {"is_loop": False, "bus": "fx"},
"resample_downlifter": {"is_loop": False, "bus": "fx"},
"resample_stutter": {"is_loop": False, "bus": "vocal"},
}
# Keywords that indicate aggressive/hard samples that may be misclassified
AGGRESSIVE_KEYWORDS = {
# Very aggressive kick patterns
"hard", "distorted", "industrial", "slam", "punch", "brutal",
# Potentially misclassified
"subdrop", "impact", "explosion", "destroy",
}
# Keywords that are acceptable for aggressive genres
GENRE_APPROPRIATE_AGGRESSIVE = {
"industrial-techno", "hard-techno", "raw-techno", "psytrance", "dark-techno"
}
# Role aliases for flexible matching
ROLE_ALIASES = {
"kick": ["kick", "bd", "bassdrum", "bass_drum"],
"snare": ["snare", "sd", "snr"],
"clap": ["clap", "cp", "handclap"],
"hat": ["hat", "hihat", "hi_hat", "hhat", "closed_hat", "hat_closed"],
"hat_open": ["open_hat", "hat_open", "ohat", "openhihat"],
"ride": ["ride", "rd", "cymbal"],
"perc": ["perc", "percussion", "percs"],
"bass_loop": ["bass_loop", "bassloop", "bass loop", "sub_bass"],
"perc_loop": ["perc_loop", "percloop", "percussion loop", "perc loop"],
"top_loop": ["top_loop", "toploop", "top loop", "full_drum"],
"synth_loop": ["synth_loop", "synthloop", "synth loop", "chord_loop", "stab"],
"vocal_loop": ["vocal_loop", "vocalloop", "vocal loop", "vox_loop", "vox"],
"crash_fx": ["crash", "crash_fx", "crashfx", "impact_fx"],
"fill_fx": ["fill", "fill_fx", "fillfx", "tom_fill", "transition"],
"snare_roll": ["snare_roll", "snareroll", "snare roll", "snr_roll"],
"atmos_fx": ["atmos", "atmos_fx", "atmosfx", "drone", "pad_fx"],
"vocal_shot": ["vocal_shot", "vocalshot", "vocal shot", "vocal_one_shot"],
}
# Minimum score thresholds for role matching
ROLE_SCORE_THRESHOLDS = {
"kick": 0.35,
"snare": 0.32,
"hat": 0.30,
"clap": 0.32,
"bass_loop": 0.38,
"perc_loop": 0.35,
"top_loop": 0.35,
"synth_loop": 0.36,
"vocal_loop": 0.38,
"crash_fx": 0.30,
"fill_fx": 0.32,
"snare_roll": 0.30,
"atmos_fx": 0.32,
"vocal_shot": 0.34,
}
# ============================================================================
# VALIDATION FUNCTIONS
# ============================================================================
def validate_role_for_sample(
role: str,
sample_data: Dict[str, Any],
genre: Optional[str] = None,
) -> Dict[str, Any]:
"""
Validates if a sample is appropriate for a given role.
Args:
role: The role to validate for (e.g., 'kick', 'bass_loop')
sample_data: Sample metadata with keys like 'duration', 'onset_mean', 'file_name', 'rms_mean'
genre: Optional genre for context-aware aggressive sample handling
Returns:
Dict with keys:
- 'valid' (bool): Whether the sample passes validation
- 'score' (float): Raw validation score (0.0-1.0)
- 'warnings' (list): List of warning messages
- 'adjusted_score' (float): Score after penalties
"""
if role not in VALID_ROLES:
return {"valid": True, "score": 0.5, "warnings": [f"Unknown role: {role}"], "adjusted_score": 0.5}
role_config = VALID_ROLES[role]
warnings: List[str] = []
score = 1.0
duration = float(sample_data.get("duration", 0.0) or 0.0)
onset = float(sample_data.get("onset_mean", 0.0) or 0.0)
file_name = str(sample_data.get("file_name", "") or "").lower()
rms = float(sample_data.get("rms_mean", 0.0) or 0.0)
# Duration validation
if role_config.get("is_loop"):
min_dur = role_config.get("min_duration", 2.0)
max_dur = role_config.get("max_duration", 16.0)
if duration < min_dur:
warnings.append(f"Duration {duration:.1f}s too short for loop role (min {min_dur}s)")
score *= 0.7
elif max_dur and duration > max_dur:
warnings.append(f"Duration {duration:.1f}s too long for role (max {max_dur}s)")
score *= 0.85
else:
max_dur = role_config.get("max_duration", 3.0)
if duration > max_dur:
warnings.append(f"Duration {duration:.1f}s too long for one-shot role (max {max_dur}s)")
score *= 0.75
if "loop" in file_name and role in ["kick", "snare", "hat", "clap"]:
warnings.append("One-shot role has 'loop' in filename")
score *= 0.65
# Onset validation for percussive elements
min_onset = role_config.get("min_onset", 0.0)
if min_onset > 0 and onset < min_onset:
warnings.append(f"Onset {onset:.2f} below minimum {min_onset:.2f}")
score *= 0.85
# Check for aggressive samples that might be misclassified
aggressive_penalty = 1.0
is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE
for keyword in AGGRESSIVE_KEYWORDS:
if keyword in file_name:
if not is_aggressive_genre:
aggressive_penalty *= 0.88
warnings.append(f"Aggressive keyword '{keyword}' found for non-aggressive genre")
score *= aggressive_penalty
# RMS validation for certain roles
if role in ["kick", "snare", "clap"] and rms > 0.4:
warnings.append(f"High RMS {rms:.3f} for one-shot role")
score *= 0.9
adjusted_score = max(0.1, min(1.0, score))
return {
"valid": score >= 0.4,
"score": score,
"warnings": warnings,
"adjusted_score": adjusted_score,
}
def resolve_role_from_alias(alias: str) -> Optional[str]:
"""
Resolves a role name from various aliases.
Args:
alias: A potential role alias (e.g., 'bd', 'hihat', 'bass loop')
Returns:
The canonical role name or None if not found
"""
alias_lower = alias.lower().strip().replace("-", "_").replace(" ", "_")
# Direct match
if alias_lower in VALID_ROLES:
return alias_lower
# Check aliases
for role, aliases in ROLE_ALIASES.items():
normalized_aliases = [a.lower().replace("-", "_").replace(" ", "_") for a in aliases]
if alias_lower in normalized_aliases:
return role
return None
def get_bus_for_role(role: str) -> str:
"""
Gets the appropriate bus for a role.
Args:
role: The role name
Returns:
Bus name ('drums', 'bass', 'music', 'vocal', or 'fx')
"""
if role in VALID_ROLES:
return VALID_ROLES[role].get("bus", "music")
return "music"
# ============================================================================
# LOGGING FUNCTIONS
# ============================================================================
def log_matching_decision(
role: str,
selected_sample: Optional[Dict[str, Any]],
candidates_count: int,
final_score: float,
validation_result: Optional[Dict[str, Any]] = None,
) -> None:
"""
Logs detailed matching decisions for debugging and analysis.
Args:
role: The role being matched
selected_sample: The selected sample dict or None
candidates_count: Number of candidates considered
final_score: The final matching score
validation_result: Optional validation result dict
"""
if not selected_sample:
logger.info(
f"[MATCH] Role '{role}': No sample selected (0/{candidates_count} candidates)"
)
return
sample_name = selected_sample.get("file_name", "unknown")
sample_tempo = selected_sample.get("tempo", 0.0)
sample_key = selected_sample.get("key", "N/A")
sample_dur = selected_sample.get("duration", 0.0)
log_parts = [
f"[MATCH] Role '{role}':",
f"Sample: {sample_name}",
f"Score: {final_score:.3f}",
f"Tempo: {sample_tempo:.1f}",
f"Key: {sample_key}",
f"Duration: {sample_dur:.1f}s",
f"Candidates: {candidates_count}",
]
if validation_result:
warnings = validation_result.get("warnings", [])
if warnings:
log_parts.append(f"Warnings: {', '.join(warnings)}")
log_parts.append(f"Validated: {validation_result.get('valid', True)}")
logger.info(" | ".join(log_parts))
# ============================================================================
# ENHANCEMENT FUNCTIONS
# ============================================================================
def enhance_sample_matching(
matches: Dict[str, List[Dict[str, Any]]],
reference: Dict[str, Any],
genre: Optional[str] = None,
) -> Dict[str, List[Dict[str, Any]]]:
"""
Enhances sample matching results with validation and filtering.
This function takes raw matches from reference_listener and applies:
1. Role validation based on audio characteristics
2. Aggressive sample filtering
3. Score adjustment based on validation results
Args:
matches: Raw matches from reference_listener (role -> list of sample dicts)
reference: Reference track analysis data
genre: Target genre for context-aware filtering
Returns:
Enhanced matches with validation scores and filtering applied
"""
enhanced: Dict[str, List[Dict[str, Any]]] = {}
for role, candidates in matches.items():
if not candidates:
enhanced[role] = []
continue
threshold = ROLE_SCORE_THRESHOLDS.get(role, 0.30)
enhanced_candidates: List[Dict[str, Any]] = []
for candidate in candidates:
# Create a copy to avoid modifying the original
enhanced_candidate = dict(candidate)
# Validate the sample for this role
validation = validate_role_for_sample(role, candidate, genre)
enhanced_candidate["validation"] = validation
# Apply validation penalty to the score
original_score = float(candidate.get("score", 0.0))
adjusted_score = original_score * validation["adjusted_score"]
enhanced_candidate["adjusted_score"] = round(adjusted_score, 6)
# Filter out samples below threshold
if adjusted_score >= threshold:
enhanced_candidates.append(enhanced_candidate)
else:
logger.debug(
f"[FILTER] Role '{role}': Filtered out '{candidate.get('file_name', 'unknown')}' "
f"(score {adjusted_score:.3f} < threshold {threshold})"
)
# Re-sort by adjusted score
enhanced_candidates.sort(key=lambda x: float(x.get("adjusted_score", 0.0)), reverse=True)
enhanced[role] = enhanced_candidates
# Log summary
filtered_count = len(candidates) - len(enhanced_candidates)
if filtered_count > 0:
logger.info(
f"[ENHANCE] Role '{role}': {len(enhanced_candidates)}/{len(candidates)} candidates passed validation "
f"({filtered_count} filtered out)"
)
return enhanced
def filter_aggressive_samples(
candidates: List[Dict[str, Any]],
genre: Optional[str] = None,
strict: bool = False,
) -> List[Dict[str, Any]]:
"""
Filters out samples with aggressive keywords unless appropriate for the genre.
Args:
candidates: List of sample candidate dicts
genre: Target genre
strict: If True, apply stricter filtering
Returns:
Filtered list of candidates
"""
is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE
if is_aggressive_genre:
# For aggressive genres, don't filter aggressive samples
return candidates
filtered = []
for candidate in candidates:
file_name = str(candidate.get("file_name", "") or "").lower()
aggressive_count = sum(1 for kw in AGGRESSIVE_KEYWORDS if kw in file_name)
if strict and aggressive_count > 0:
continue
# Apply penalty instead of filtering completely
if aggressive_count > 0:
penalty = 0.85 ** aggressive_count
candidate_copy = dict(candidate)
original_score = float(candidate.get("score", 0.0))
candidate_copy["score"] = original_score * penalty
filtered.append(candidate_copy)
else:
filtered.append(candidate)
return filtered
# ============================================================================
# INTEGRATION HELPERS
# ============================================================================
def create_enhanced_match_report(
role: str,
selected_sample: Optional[Dict[str, Any]],
all_candidates: List[Dict[str, Any]],
validation_result: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Creates a detailed report for a matching decision.
Args:
role: The role being matched
selected_sample: The selected sample
all_candidates: All candidates that were considered
validation_result: Validation result for the selected sample
Returns:
A dict with detailed matching report
"""
report = {
"role": role,
"selected": selected_sample is not None,
"candidates_count": len(all_candidates),
"threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30),
}
if selected_sample:
report["selected_sample"] = {
"name": selected_sample.get("file_name"),
"path": selected_sample.get("path"),
"score": selected_sample.get("score"),
"adjusted_score": selected_sample.get("adjusted_score"),
"tempo": selected_sample.get("tempo"),
"key": selected_sample.get("key"),
"duration": selected_sample.get("duration"),
}
if validation_result:
report["validation"] = {
"valid": validation_result.get("valid"),
"score": validation_result.get("score"),
"warnings": validation_result.get("warnings", []),
}
return report
def get_role_info(role: str) -> Dict[str, Any]:
"""
Gets comprehensive information about a role.
Args:
role: The role name
Returns:
Dict with role information including valid samples count, thresholds, etc.
"""
if role not in VALID_ROLES:
return {"error": f"Unknown role: {role}"}
config = VALID_ROLES[role]
aliases = ROLE_ALIASES.get(role, [])
return {
"role": role,
"config": config,
"aliases": aliases,
"threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30),
"bus": config.get("bus", "music"),
"is_loop": config.get("is_loop", False),
}

View File

@@ -0,0 +1,308 @@
"""
sample_index.py - Índice y búsqueda de samples para AbletonMCP-AI
Gestiona la librería de samples locales con metadatos extraídos de los nombres.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
import re
logger = logging.getLogger("SampleIndex")
class SampleIndex:
"""Índice de samples con búsqueda y metadatos"""
# Categorías por palabras clave
CATEGORIES = {
'kick': ['kick', 'bd', 'bass drum', 'kick drum'],
'snare': ['snare', 'sd', 'snr'],
'clap': ['clap', 'clp'],
'hat': ['hat', 'hh', 'hihat', 'hi-hat', 'closed hat', 'open hat'],
'perc': ['perc', 'percussion', 'conga', 'bongo', 'shaker', 'tamb', 'timb'],
'bass': ['bass', 'bassline', 'sub', '808', ' Reese'],
'synth': ['synth', 'lead', 'pad', 'arp', 'pluck', 'stab', 'chord'],
'vocal': ['vocal', 'vox', 'voice', 'speech', 'talk'],
'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit'],
'loop': ['loop', 'full', 'groove'],
}
def __init__(self, base_dir: str):
"""
Inicializa el índice de samples
Args:
base_dir: Directorio base donde buscar samples
"""
self.base_dir = Path(base_dir)
self.samples: List[Dict[str, Any]] = []
self.index_file = self.base_dir / ".sample_index.json"
# Cargar o construir índice
if self.index_file.exists():
self._load_index()
else:
self._build_index()
self._save_index()
def _build_index(self):
"""Construye el índice escaneando el directorio"""
logger.info(f"Construyendo índice de samples en: {self.base_dir}")
extensions = {'.wav', '.aif', '.aiff', '.mp3', '.ogg'}
for file_path in self.base_dir.rglob('*'):
if file_path.suffix.lower() in extensions:
sample_info = self._analyze_sample(file_path)
self.samples.append(sample_info)
logger.info(f"Índice construido: {len(self.samples)} samples encontrados")
def _analyze_sample(self, file_path: Path) -> Dict[str, Any]:
"""Analiza un sample y extrae metadatos del nombre"""
name = file_path.stem
name_lower = name.lower()
# Determinar categoría
category = self._detect_category(name_lower)
# Extraer key del nombre
key = self._extract_key(name)
# Extraer BPM del nombre
bpm = self._extract_bpm(name)
return {
'name': name,
'path': str(file_path),
'category': category,
'key': key,
'bpm': bpm,
'size': file_path.stat().st_size if file_path.exists() else 0,
}
def _detect_category(self, name: str) -> str:
"""Detecta la categoría basada en palabras clave"""
for category, keywords in self.CATEGORIES.items():
for keyword in keywords:
if keyword in name:
return category
return 'unknown'
def _extract_key(self, name: str) -> Optional[str]:
"""Extrae la tonalidad del nombre del archivo"""
# Patrones comunes: "Key A", "in A", "A minor", "Am", "F#m", etc.
patterns = [
r'[_\s\-]([A-G][#b]?m?)\s*(?:minor|major)?[_\s\-]?',
r'[_\s\-]([A-G][#b]?)[_\s\-]',
r'\bin\s+([A-G][#b]?m?)\b',
r'Key\s+([A-G][#b]?m?)',
]
for pattern in patterns:
match = re.search(pattern, name, re.IGNORECASE)
if match:
key = match.group(1)
# Normalizar
key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#')
key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#')
return key
return None
def _extract_bpm(self, name: str) -> Optional[int]:
"""Extrae el BPM del nombre del archivo"""
# Patrones: "128 BPM", "_128_", "128bpm", etc.
patterns = [
r'[_\s\-](\d{2,3})\s*BPM',
r'[_\s\-](\d{2,3})[_\s\-]',
r'(\d{2,3})bpm',
]
for pattern in patterns:
match = re.search(pattern, name, re.IGNORECASE)
if match:
bpm = int(match.group(1))
if 60 <= bpm <= 200: # Rango razonable
return bpm
return None
def _load_index(self):
"""Carga el índice desde archivo"""
try:
with open(self.index_file, 'r') as f:
data = json.load(f)
self.samples = data.get('samples', [])
logger.info(f"Índice cargado: {len(self.samples)} samples")
except Exception as e:
logger.error(f"Error cargando índice: {e}")
self._build_index()
def _save_index(self):
"""Guarda el índice a archivo"""
try:
with open(self.index_file, 'w') as f:
json.dump({
'samples': self.samples,
'base_dir': str(self.base_dir)
}, f, indent=2)
logger.info(f"Índice guardado en: {self.index_file}")
except Exception as e:
logger.error(f"Error guardando índice: {e}")
def search(self, query: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]:
"""
Busca samples por query y/o categoría
Args:
query: Término de búsqueda
category: Categoría específica (opcional)
limit: Número máximo de resultados
Returns:
Lista de samples que coinciden
"""
query_lower = query.lower()
results = []
for sample in self.samples:
# Filtrar por categoría si se especificó
if category and sample['category'] != category.lower():
continue
# Buscar en nombre
name = sample['name'].lower()
if query_lower in name:
# Calcular score de relevancia
score = 0
if query_lower == sample.get('category', ''):
score += 10 # Coincidencia exacta de categoría
if query_lower in name.split('_'):
score += 5 # Palabra completa
if name.startswith(query_lower):
score += 3 # Comienza con el término
results.append((score, sample))
# Ordenar por score y limitar
results.sort(key=lambda x: x[0], reverse=True)
return [sample for _, sample in results[:limit]]
def find_by_key(self, key: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]:
"""Busca samples por tonalidad"""
results = []
for sample in self.samples:
if sample.get('key') == key:
if not category or sample['category'] == category:
results.append(sample)
return results[:limit]
def find_by_bpm(self, bpm: int, tolerance: int = 5, limit: int = 10) -> List[Dict[str, Any]]:
"""Busca samples por BPM con tolerancia"""
results = []
for sample in self.samples:
sample_bpm = sample.get('bpm')
if sample_bpm and abs(sample_bpm - bpm) <= tolerance:
results.append(sample)
return results[:limit]
def get_random_sample(self, category: str = "") -> Optional[Dict[str, Any]]:
"""Obtiene un sample aleatorio, opcionalmente filtrado por categoría"""
import random
samples = self.samples
if category:
samples = [s for s in samples if s['category'] == category]
return random.choice(samples) if samples else None
def get_sample_pack(self, genre: str, key: str = "", bpm: int = 0) -> Dict[str, List[Dict]]:
"""
Obtiene un pack de samples completo para un género
Args:
genre: Género musical
key: Tonalidad preferida
bpm: BPM preferido
Returns:
Dict con samples organizados por categoría
"""
pack = {
'kick': [],
'snare': [],
'hat': [],
'clap': [],
'perc': [],
'bass': [],
'synth': [],
'fx': [],
}
# Seleccionar un sample de cada categoría
for category in pack.keys():
candidates = [s for s in self.samples if s['category'] == category]
# Filtrar por key si se especificó
if key and candidates:
key_matches = [s for s in candidates if s.get('key') == key]
if key_matches:
candidates = key_matches
# Filtrar por BPM si se especificó
if bpm and candidates:
bpm_matches = [s for s in candidates if s.get('bpm')]
if bpm_matches:
# Ordenar por cercanía al BPM objetivo
bpm_matches.sort(key=lambda s: abs(s['bpm'] - bpm))
candidates = bpm_matches[:5] # Top 5 más cercanos
# Seleccionar hasta 3 samples
import random
if candidates:
pack[category] = random.sample(candidates, min(3, len(candidates)))
return pack
def refresh(self):
"""Reconstruye el índice desde cero"""
logger.info("Refrescando índice...")
self._build_index()
self._save_index()
# Función de utilidad para testing
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Uso: python sample_index.py <directorio_de_samples>")
sys.exit(1)
logging.basicConfig(level=logging.INFO)
index = SampleIndex(sys.argv[1])
print(f"\nÍndice cargado: {len(index.samples)} samples")
print("\nDistribución por categoría:")
categories = {}
for sample in index.samples:
cat = sample['category']
categories[cat] = categories.get(cat, 0) + 1
for cat, count in sorted(categories.items(), key=lambda x: -x[1]):
print(f" {cat}: {count}")
# Ejemplo de búsqueda
print("\nBúsqueda 'kick':")
for s in index.search("kick", limit=5):
print(f" - {s['name']} ({s.get('key', '?')}, {s.get('bpm', '?')} BPM)")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
"""
Demo del Sistema de Gestión de Samples para AbletonMCP-AI
Este script demuestra las capacidades del sistema completo de samples.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from sample_manager import get_manager
from sample_selector import get_selector
from audio_analyzer import analyze_sample, AudioAnalyzer
def demo_analyzer():
"""Demostración del analizador de audio"""
print("=" * 60)
print("DEMO: Audio Analyzer")
print("=" * 60)
AudioAnalyzer(backend='basic')
# Analizar un archivo de ejemplo
test_file = r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav"
print(f"\nAnalizando: {Path(test_file).name}")
print("-" * 40)
try:
result = analyze_sample(test_file)
print(f"Tipo detectado: {result['sample_type']}")
print(f"BPM: {result.get('bpm') or 'No detectado'}")
print(f"Key: {result.get('key') or 'No detectado'}")
print(f"Duración: {result['duration']:.3f}s")
print(f"Es percusivo: {result['is_percussive']}")
print(f"Géneros sugeridos: {', '.join(result['suggested_genres'])}")
except Exception as e:
print(f"Error: {e}")
print()
def demo_manager():
"""Demostración del gestor de samples"""
print("=" * 60)
print("DEMO: Sample Manager")
print("=" * 60)
manager = get_manager(r"C:\Users\ren\embeddings\all_tracks")
# Escanear librería
print("\nEscaneando librería...")
stats = manager.scan_directory()
print(f" Samples procesados: {stats['processed']}")
print(f" Nuevos: {stats['added']}")
print(f" Total en librería: {stats['total_samples']}")
# Estadísticas
print("\nEstadísticas:")
stats = manager.get_stats()
print(f" Total: {stats['total_samples']} samples")
print(f" Tamaño: {stats['total_size'] / (1024**2):.1f} MB")
if stats['by_category']:
print("\n Por categoría:")
for cat, count in sorted(stats['by_category'].items(), key=lambda x: -x[1]):
print(f" {cat}: {count}")
if stats['by_key']:
print("\n Por key:")
for key, count in sorted(stats['by_key'].items(), key=lambda x: -x[1]):
print(f" {key}: {count}")
# Búsquedas
print("\nBúsquedas:")
print("-" * 40)
# Buscar kicks
kicks = manager.search(sample_type="kick", limit=3)
print(f"\nKicks encontrados: {len(kicks)}")
for s in kicks:
print(f" - {s.name}")
# Buscar por key
g_sharp = manager.search(key="G#m", limit=3)
print(f"\nSamples en G#m: {len(g_sharp)}")
for s in g_sharp:
print(f" - {s.name} ({s.sample_type})")
# Buscar por BPM
bpm_128 = manager.search(bpm=128, bpm_tolerance=5, limit=3)
print(f"\nSamples ~128 BPM: {len(bpm_128)}")
for s in bpm_128:
key_info = f" [{s.key}]" if s.key else ""
print(f" - {s.name}{key_info}")
print()
def demo_selector():
"""Demostración del selector inteligente"""
print("=" * 60)
print("DEMO: Sample Selector")
print("=" * 60)
selector = get_selector()
# Seleccionar para diferentes géneros
genres = ['techno', 'house', 'tech-house']
for genre in genres:
print(f"\n{genre.upper()}:")
print("-" * 40)
group = selector.select_for_genre(genre, key='Am', bpm=128)
print(f" Key: {group.key} | BPM: {group.bpm}")
# Drum kit
kit = group.drums
print("\n Drum Kit:")
if kit.kick:
print(f" Kick: {kit.kick.name}")
if kit.snare:
print(f" Snare: {kit.snare.name}")
if kit.clap:
print(f" Clap: {kit.clap.name}")
if kit.hat_closed:
print(f" Hat: {kit.hat_closed.name}")
# Mapeo MIDI
mapping = selector.get_midi_mapping_for_kit(kit)
print("\n Mapeo MIDI:")
for note, info in sorted(mapping['notes'].items())[:4]:
if info['sample']:
print(f" Note {note}: {info['sample'][:40]}...")
# Bass
if group.bass:
print(f"\n Bass ({len(group.bass)}):")
for s in group.bass[:2]:
key_info = f" [{s.key}]" if s.key else ""
print(f" - {s.name}{key_info}")
# Cambio de key
print("\n" + "-" * 40)
print("Cambios de Key Sugeridos (desde Am):")
changes = ['fifth_up', 'fifth_down', 'relative', 'parallel']
for change in changes:
new_key = selector.suggest_key_change('Am', change)
print(f" {change}: {new_key}")
print()
def demo_compatibility():
"""Demostración de búsqueda de samples compatibles"""
print("=" * 60)
print("DEMO: Compatibilidad de Samples")
print("=" * 60)
manager = get_manager()
selector = get_selector()
# Encontrar un sample con key para usar de referencia
samples_with_key = manager.search(key="G#m", limit=1)
if samples_with_key:
reference = samples_with_key[0]
print(f"\nSample de referencia: {reference.name}")
print(f" Key: {reference.key} | BPM: {reference.bpm}")
# Buscar compatibles
compatible = selector.find_compatible_samples(reference, max_results=5)
print("\nSamples compatibles:")
print("-" * 40)
for sample, score in compatible:
bar_len = int(score * 20)
bar = "" * bar_len + "" * (20 - bar_len)
print(f" [{bar}] {score:.1%} - {sample.name}")
print()
def demo_pack_generation():
"""Demostración de generación de packs"""
print("=" * 60)
print("DEMO: Generación de Sample Packs")
print("=" * 60)
manager = get_manager()
genres = ['techno', 'house', 'deep-house']
for genre in genres:
print(f"\n{genre.upper()} Pack:")
print("-" * 40)
pack = manager.get_pack_for_genre(genre, key='Am', bpm=128)
total = 0
for category, samples in pack.items():
if samples:
count = len(samples)
total += count
print(f" {category}: {count}")
print(f" Total: {total} samples")
print()
def main():
"""Ejecutar todas las demos"""
print("\n")
print("=" * 60)
print(" AbletonMCP-AI Sample System Demo ".center(60))
print("=" * 60)
print()
try:
demo_analyzer()
demo_manager()
demo_selector()
demo_compatibility()
demo_pack_generation()
print("=" * 60)
print("Todas las demos completadas exitosamente!")
print("=" * 60)
except Exception as e:
print(f"\nError en demo: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,198 @@
"""
segment_rag_builder.py - Build or refresh the persistent segment-audio index.
"""
from __future__ import annotations
import argparse
import json
import logging
from pathlib import Path
from reference_listener import ReferenceAudioListener, export_segment_rag_manifest, generate_segment_rag_summary, _get_segment_rag_status, _backfill_segment_cache_metadata
logger = logging.getLogger(__name__)
def _default_library_dir() -> Path:
return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks"
def main() -> int:
parser = argparse.ArgumentParser(description="Build the persistent segment-audio retrieval cache.")
parser.add_argument("--library-dir", default=str(_default_library_dir()), help="Audio library directory")
parser.add_argument("--roles", nargs="*", default=None, help="Subset of roles to index")
parser.add_argument("--max-files", type=int, default=None, help="Optional limit for targeted files")
parser.add_argument("--duration-limit", type=float, default=24.0, help="Max seconds per file during indexing")
parser.add_argument("--force", action="store_true", help="Rebuild even if persistent segment cache already exists")
parser.add_argument("--json", action="store_true", help="Emit full JSON report")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
parser.add_argument("--offset", type=int, default=0, help="Skip first N files before starting (for chunked indexing)")
parser.add_argument("--batch-size", type=int, default=None, help="Process exactly N files then stop (for chunked indexing)")
parser.add_argument("--output-manifest", type=str, default=None, help="Path to save full manifest JSON")
parser.add_argument("--output-summary", type=str, default=None, help="Path to save summary report")
parser.add_argument("--resume", action="store_true", help="Resume from previous run state")
parser.add_argument("--export-manifest", type=str, default=None,
help="Export candidate manifest to FILE (format: .json or .md)")
parser.add_argument("--export-format", type=str, default="json",
choices=['json', 'markdown'], help="Manifest export format")
parser.add_argument("--status", action="store_true", help="Show current index status without building")
parser.add_argument("--backfill-metadata", action="store_true", help="Backfill metadata into existing cache files from indexing state")
parser.add_argument("--force-backfill", action="store_true", help="Force backfill even for files that already have metadata")
args = parser.parse_args()
# Configure logging based on verbose flag
if args.verbose:
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
else:
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# Handle --status flag for early exit
if args.status:
status = _get_segment_rag_status(Path(args.library_dir))
if args.json:
print(json.dumps(status, indent=2, default=str))
else:
print("=" * 60)
print("SEGMENT RAG INDEX STATUS")
print("=" * 60)
print(f"Cache Directory: {status['cache_dir']}")
print(f"Cache Files: {status['cache_files']}")
print(f"Total Indexed Segments: {status['total_segments']}")
print(f"Status: {status.get('status', 'unknown')}")
if status.get('role_coverage'):
print("\nRole Coverage:")
for role, count in sorted(status['role_coverage'].items()):
print(f" {role}: {count} segments")
if status.get('newest_entries'):
print(f"\nNewest Entries: {len(status['newest_entries'])} files")
for entry in status['newest_entries'][:5]:
print(f" - {entry['file_name']} ({entry['segments']} segments)")
if status.get('oldest_entries'):
print(f"\nOldest Entries: {len(status['oldest_entries'])} files")
for entry in status['oldest_entries'][:5]:
print(f" - {entry['file_name']} ({entry['segments']} segments)")
return 0
# Handle --backfill-metadata flag for early exit
if args.backfill_metadata:
result = _backfill_segment_cache_metadata(Path(args.library_dir), force=args.force_backfill)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print("=" * 60)
print("SEGMENT CACHE METADATA BACKFILL")
print("=" * 60)
print(f"Cache Directory: {result['cache_dir']}")
print(f"Cache Files: {result['cache_files']}")
print(f"Backfilled: {result['backfilled']}")
print(f"Skipped: {result['skipped']}")
print(f"Errors: {result['errors']}")
print(f"Status: {result.get('status', 'unknown')}")
return 0
listener = ReferenceAudioListener(args.library_dir)
report = listener.build_segment_rag_index(
roles=args.roles,
max_files=args.max_files,
duration_limit=args.duration_limit,
force=args.force,
offset=args.offset,
batch_size=args.batch_size,
resume=args.resume,
)
# Generate enhanced summary
summary = generate_segment_rag_summary(report, Path(args.library_dir))
if args.json:
print(json.dumps(summary, indent=2, default=str))
else:
# Enhanced text output
print("=" * 60)
print("SEGMENT RAG INDEX COMPLETE")
print("=" * 60)
print(f"Device: {summary['device']}")
print(f"Cache: {summary['segment_index_dir']}")
print()
print(f"Files: {summary['files_targeted']} targeted")
print(f" Built: {summary['built']}")
print(f" Reused: {summary['reused']}")
print(f" Skipped: {summary['skipped']}")
print(f" Errors: {summary['errors']}")
print()
print(f"Total Segments: {summary['total_segments']}")
if 'summary_stats' in summary:
stats = summary['summary_stats']
print(f" Avg per file: {stats['avg_segments_per_file']:.1f}")
print(f" Range: {stats['min_segments']} - {stats['max_segments']}")
if 'role_coverage' in summary:
print("\nRole Coverage:")
for role in sorted(summary['role_coverage'].keys()):
print(f" {role}: {summary['role_coverage'][role]} segments")
if 'cache_info' in summary:
info = summary['cache_info']
print(f"\nCache Size: {info['cache_size_mb']} MB")
if args.offset > 0:
print(f"\nOffset: {args.offset}")
if args.batch_size is not None:
print(f"Batch Size: {args.batch_size}")
print(f"Files Remaining: {summary.get('files_remaining', 'unknown')}")
# Save manifest if requested
if args.output_manifest:
manifest_path = Path(args.output_manifest)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
with open(manifest_path, 'w') as f:
json.dump({
"report": report,
"full_manifest": report.get("manifest", []),
}, f, indent=2)
if not args.json:
print(f"\nManifest saved to: {manifest_path}")
# Save summary if requested
if args.output_summary:
summary_path = Path(args.output_summary)
summary_path.parent.mkdir(parents=True, exist_ok=True)
with open(summary_path, 'w') as f:
json.dump(summary, f, indent=2, default=str)
if not args.json:
print(f"Summary saved to: {summary_path}")
# Export manifest in requested format
if args.export_manifest:
manifest_path = Path(args.export_manifest)
export_format = args.export_format
# Determine format from extension if not specified
if not args.export_format or args.export_format == "json":
if manifest_path.suffix == '.md':
export_format = 'markdown'
else:
export_format = 'json'
export_segment_rag_manifest(
report.get('manifest', []),
manifest_path,
format=export_format
)
print(f"Manifest exported to: {manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,798 @@
import argparse
import json
import socket
from datetime import datetime
from typing import Any, Dict, List, Tuple
try:
from song_generator import SongGenerator
except ImportError:
SongGenerator = None
STRUCTURE_SCENE_COUNTS = {
"minimal": 4,
"standard": 6,
"extended": 7,
}
# Expected buses for Phase 7 validation
EXPECTED_BUSES = ["drums", "bass", "music", "vocal", "fx"]
EXPECTED_CRITICAL_ROLES = {"kick", "bass", "clap", "hat"}
EXPECTED_AUDIO_FX_LAYERS = ["AUDIO ATMOS", "AUDIO CRASH FX", "AUDIO TRANSITION FILL"]
EXPECTED_BUS_NAMES = ["DRUMS", "BASS", "MUSIC"]
MIN_TRACKS_FOR_EXPORT = 6
MIN_BUSES_FOR_EXPORT = 3
MIN_RETURNS_FOR_EXPORT = 2
MASTER_VOLUME_RANGE = (0.75, 0.95)
# Expected AUDIO RESAMPLE track names
AUDIO_RESAMPLE_TRACKS = [
"AUDIO RESAMPLE REVERSE FX",
"AUDIO RESAMPLE RISER",
"AUDIO RESAMPLE DOWNLIFTER",
"AUDIO RESAMPLE STUTTER",
]
# Bus routing map: track role -> expected bus output
BUS_ROUTING_MAP = {
"kick": {"drums"},
"snare": {"drums"},
"clap": {"drums"},
"hat": {"drums"},
"perc": {"drums"},
"sub_bass": {"bass"},
"bass": {"bass"},
"chords": {"music"},
"pad": {"music"},
"pluck": {"music"},
"lead": {"music"},
"vocal": {"vocal"},
"vocal_chop": {"vocal"},
"reverse_fx": {"fx"},
"riser": {"fx"},
"impact": {"fx"},
"atmos": {"fx"},
"crash": {"drums", "fx"},
}
def _extract_bus_payload(payload: Any) -> List[Dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
buses = payload.get("buses", [])
if isinstance(buses, list):
return [item for item in buses if isinstance(item, dict)]
return []
def _normalize_bus_key(name: str) -> str:
normalized = "".join(ch for ch in (name or "").lower() if ch.isalnum())
if not normalized:
return ""
if "drum" in normalized or "groove" in normalized:
return "drums"
if "bass" in normalized or "tube" in normalized or "subdeep" in normalized:
return "bass"
if "music" in normalized or "wide" in normalized:
return "music"
if "vocal" in normalized or "vox" in normalized or "tail" in normalized:
return "vocal"
if "fx" in normalized or "wash" in normalized:
return "fx"
return ""
def _canonical_track_name(name: str) -> str:
text = (name or "").strip().lower()
if not text:
return ""
if " (" in text:
text = text.split(" (", 1)[0].strip()
return text
class AbletonSocketClient:
def __init__(self, host: str = "127.0.0.1", port: int = 9877, timeout: float = 15.0):
self.host = host
self.port = port
self.timeout = timeout
def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
payload = json.dumps({
"type": command_type,
"params": params or {},
}).encode("utf-8") + b"\n"
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(payload)
reader = sock.makefile("r", encoding="utf-8")
try:
line = reader.readline()
finally:
reader.close()
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
if not line:
raise RuntimeError(f"No response for command: {command_type}")
return json.loads(line)
def expect_success(name: str, response: Dict[str, Any]) -> Dict[str, Any]:
if response.get("status") != "success":
raise RuntimeError(f"{name} failed: {response}")
return response.get("result", {})
class TestResult:
"""Tracks test results for reporting."""
def __init__(self):
self.passed: List[Tuple[str, str]] = []
self.failed: List[Tuple[str, str]] = []
self.skipped: List[Tuple[str, str]] = []
self.warnings: List[Tuple[str, str]] = []
def add_pass(self, name: str, details: str = ""):
self.passed.append((name, details))
def add_fail(self, name: str, error: str):
self.failed.append((name, error))
def add_skip(self, name: str, reason: str):
self.skipped.append((name, reason))
def add_warning(self, name: str, message: str):
self.warnings.append((name, message))
def to_dict(self) -> Dict[str, Any]:
return {
"summary": {
"total": len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings),
"passed": len(self.passed),
"failed": len(self.failed),
"skipped": len(self.skipped),
"warnings": len(self.warnings),
"status": "PASS" if len(self.failed) == 0 else "FAIL",
},
"passed_tests": [{"name": n, "details": d} for n, d in self.passed],
"failed_tests": [{"name": n, "error": d} for n, d in self.failed],
"skipped_tests": [{"name": n, "reason": d} for n, d in self.skipped],
"warnings": [{"name": n, "message": d} for n, d in self.warnings],
}
def print_report(self):
print("\n" + "=" * 60)
print("PHASE 7 SMOKE TEST REPORT")
print("=" * 60)
print(f"Timestamp: {datetime.now().isoformat()}")
print(f"Total: {len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings)}")
print(f"Passed: {len(self.passed)}")
print(f"Failed: {len(self.failed)}")
print(f"Skipped: {len(self.skipped)}")
print(f"Warnings: {len(self.warnings)}")
print("-" * 60)
if self.passed:
print("\n[PASSED]")
for name, details in self.passed:
print(f" [OK] {name}: {details}")
if self.failed:
print("\n[FAILED]")
for name, error in self.failed:
print(f" [FAIL] {name}: {error}")
if self.warnings:
print("\n[WARNINGS]")
for name, message in self.warnings:
print(f" [WARN] {name}: {message}")
if self.skipped:
print("\n[SKIPPED]")
for name, reason in self.skipped:
print(f" [SKIP] {name}: {reason}")
print("\n" + "=" * 60)
status = "PASS" if len(self.failed) == 0 else "FAIL"
print(f"FINAL STATUS: {status}")
print("=" * 60 + "\n")
def run_readonly_checks(client: AbletonSocketClient) -> List[Tuple[str, str]]:
checks = []
expect_success("get_session_info", client.send("get_session_info"))
checks.append((
"get_session_info",
# f"tempo={session.get('tempo')} tracks={session.get('num_tracks')} scenes={session.get('num_scenes')}",
))
tracks = expect_success("get_tracks", client.send("get_tracks"))
checks.append(("get_tracks", f"tracks={len(tracks)}"))
return checks
def run_generation_check(
client: AbletonSocketClient,
genre: str,
style: str,
bpm: float,
key: str,
structure: str,
use_blueprint: bool = False,
) -> List[Tuple[str, str]]:
checks = []
params = {
"genre": genre,
"style": style,
"bpm": bpm,
"key": key,
"structure": structure,
}
if use_blueprint and SongGenerator is not None:
params = SongGenerator().generate_config(genre, style, bpm, key, structure)
result = expect_success(
"generate_complete_song",
client.send("generate_complete_song", params),
)
checks.append((
"generate_complete_song",
f"tracks={result.get('tracks')} scenes={result.get('scenes')} structure={result.get('structure')}",
))
session = expect_success("post_generate_session_info", client.send("get_session_info"))
actual_scenes = session.get("num_scenes")
expected_scenes = len(params.get("sections", [])) if use_blueprint and isinstance(params, dict) and params.get("sections") else STRUCTURE_SCENE_COUNTS.get(structure.lower())
if expected_scenes is not None and actual_scenes != expected_scenes:
raise RuntimeError(
f"scene count mismatch after generate_complete_song: expected {expected_scenes}, got {actual_scenes}"
)
checks.append((
"post_generate_session_info",
f"tracks={session.get('num_tracks')} scenes={actual_scenes}",
))
return checks
def run_bus_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify buses are created correctly."""
try:
buses_payload = expect_success("list_buses", client.send("list_buses"))
buses = _extract_bus_payload(buses_payload)
bus_keys = {_normalize_bus_key(bus.get("name", "")) for bus in buses}
bus_keys.discard("")
found_buses = []
missing_buses = []
for expected in EXPECTED_BUSES:
if expected in bus_keys:
found_buses.append(expected)
else:
missing_buses.append(expected)
if found_buses:
results.add_pass("buses_found", f"found={found_buses}")
if missing_buses:
# Not a failure if buses don't exist yet - they may be created during generation
results.add_skip("buses_missing", f"not_found={missing_buses} (may be created during generation)")
else:
results.add_pass("buses_complete", "all expected buses present")
except Exception as e:
results.add_fail("buses_check", str(e))
def run_routing_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify track routing is configured correctly."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
if not tracks:
results.add_skip("routing_check", "no tracks to verify routing")
return
correct_routing = 0
incorrect_routing = []
no_routing = 0
for track in tracks:
original_track_name = track.get("name", "")
track_name = _canonical_track_name(original_track_name)
output_routing = track.get("current_output_routing", "")
output_bus_key = _normalize_bus_key(output_routing)
track_bus_key = _normalize_bus_key(track_name)
if output_routing and output_routing.lower() != "master":
correct_routing += 1
elif not output_routing:
no_routing += 1
if track_bus_key:
continue
for role, expected_bus in BUS_ROUTING_MAP.items():
if role in track_name:
if output_bus_key in expected_bus:
correct_routing += 1
elif output_routing.lower() != "master":
expected_label = "/".join(sorted(expected_bus))
incorrect_routing.append(f"{original_track_name.lower()} -> {output_routing} (expected {expected_label})")
results.add_pass("routing_summary", f"correct={correct_routing} no_routing={no_routing}")
if incorrect_routing:
results.add_fail("routing_mismatches", ", ".join(incorrect_routing[:5]))
elif correct_routing > 0:
results.add_pass("routing_correct", f"{correct_routing} tracks with non-master routing")
except Exception as e:
results.add_fail("routing_check", str(e))
def run_audio_resample_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify AUDIO RESAMPLE tracks exist."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
track_names = [t.get("name", "") for t in tracks]
found_layers = []
missing_layers = []
for expected in AUDIO_RESAMPLE_TRACKS:
if any(expected.upper() in name.upper() for name in track_names):
found_layers.append(expected)
else:
missing_layers.append(expected)
if found_layers:
results.add_pass("audio_resample_found", f"layers={found_layers}")
if missing_layers:
results.add_skip("audio_resample_missing", f"not_found={missing_layers} (may require reference audio)")
else:
results.add_pass("audio_resample_complete", "all 4 resample layers present")
# Verify they are audio tracks
for track in tracks:
name = track.get("name", "").upper()
if "AUDIO RESAMPLE" in name:
if track.get("has_audio_input"):
results.add_pass(f"audio_track_type_{name[:20]}", "correct audio track type")
else:
results.add_fail(f"audio_track_type_{name[:20]}", "expected audio track")
except Exception as e:
results.add_fail("audio_resample_check", str(e))
def run_automation_snapshot_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify automation and device parameter snapshots."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
total_devices = 0
tracks_with_devices = 0
tracks_with_automation = 0
for track in tracks:
num_devices = track.get("num_devices", 0)
if num_devices > 0:
total_devices += num_devices
tracks_with_devices += 1
# Check for arrangement clips (may contain automation)
arrangement_clips = track.get("arrangement_clip_count", 0)
if arrangement_clips > 0:
tracks_with_automation += 1
if tracks_with_devices > 0:
results.add_pass("automation_devices", f"tracks_with_devices={tracks_with_devices} total_devices={total_devices}")
else:
results.add_skip("automation_devices", "no devices found")
if tracks_with_automation > 0:
results.add_pass("automation_clips", f"tracks_with_arrangement_clips={tracks_with_automation}")
else:
results.add_skip("automation_clips", "no arrangement clips (may need to commit to arrangement)")
# Try to get device parameters for first track with devices
for i, track in enumerate(tracks):
if track.get("num_devices", 0) > 0:
try:
devices = expect_success("get_devices", client.send("get_devices", {"track_index": i}))
if devices:
params_sample = []
for dev in devices[:3]:
params = dev.get("parameters", [])
if params:
params_sample.append(f"{dev.get('name', '?')}:{len(params)}params")
if params_sample:
results.add_pass("automation_params_snapshot", ", ".join(params_sample[:3]))
break
except Exception:
pass
break
except Exception as e:
results.add_fail("automation_snapshot_check", str(e))
def run_loudness_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify basic loudness levels using output meters."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
tracks_with_signal = 0
max_level = 0.0
level_samples = []
for track in tracks:
output_level = track.get("output_meter_level", 0.0)
left = track.get("output_meter_left", 0.0)
right = track.get("output_meter_right", 0.0)
if output_level and output_level > 0:
tracks_with_signal += 1
max_level = max(max_level, output_level)
level_samples.append(f"{track.get('name', '?')[:15]}:{output_level:.2f}")
# Check for stereo balance
if left and right and left > 0 and right > 0:
balance = abs(left - right)
if balance < 0.1:
pass # Balanced stereo
if tracks_with_signal > 0:
results.add_pass("loudness_signal_detected", f"tracks_with_signal={tracks_with_signal} max_level={max_level:.3f}")
else:
results.add_skip("loudness_signal", "no signal detected (playback may be stopped)")
# Check for clipping (levels > 1.0)
if max_level > 1.0:
results.add_fail("loudness_clipping", f"max_level={max_level:.3f} indicates potential clipping")
else:
results.add_pass("loudness_no_clipping", f"max_level={max_level:.3f}")
# Sample levels for verification
if level_samples:
results.add_pass("loudness_levels", ", ".join(level_samples[:5]))
except Exception as e:
results.add_fail("loudness_check", str(e))
def run_critical_layer_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify critical layers (kick, bass, clap, hat) exist and have content."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)]
found_layers = {role: False for role in EXPECTED_CRITICAL_ROLES}
for track_name in track_names:
for role in EXPECTED_CRITICAL_ROLES:
if role.upper() in track_name or f"AUDIO {role.upper()}" in track_name:
found_layers[role] = True
break
for role, found in found_layers.items():
if found:
results.add_pass(f"critical_layer_{role}", "found in tracks")
else:
results.add_fail(f"critical_layer_{role}", "missing - set may sound incomplete")
except Exception as e:
results.add_fail("critical_layer_check", str(e))
def run_derived_fx_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify derived FX tracks (AUDIO RESAMPLE) are present."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)]
found_derived = []
missing_derived = []
for expected in AUDIO_RESAMPLE_TRACKS:
if any(expected.upper() in name for name in track_names):
found_derived.append(expected)
else:
missing_derived.append(expected)
if found_derived:
results.add_pass("derived_fx_found", f"layers={found_derived}")
if missing_derived:
results.add_skip("derived_fx_missing", f"not_found={missing_derived} (may require reference audio)")
else:
results.add_pass("derived_fx_complete", "all 4 resample layers present")
except Exception as e:
results.add_fail("derived_fx_check", str(e))
def run_export_readiness_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify set is ready for export."""
try:
expect_success("get_session_info", client.send("get_session_info"))
tracks = expect_success("get_tracks", client.send("get_tracks"))
issues = []
track_count = len(tracks) if isinstance(tracks, list) else 0
if track_count < MIN_TRACKS_FOR_EXPORT:
issues.append(f"insufficient_tracks: {track_count} (need {MIN_TRACKS_FOR_EXPORT}+)")
master_response = client.send("get_track_info", {"track_type": "master", "track_index": 0})
if master_response.get("status") == "success":
master_volume = float(master_response.get("result", {}).get("volume", 0.85))
if master_volume < MASTER_VOLUME_RANGE[0]:
issues.append(f"master_volume_low: {master_volume:.2f}")
elif master_volume > MASTER_VOLUME_RANGE[1]:
issues.append(f"master_volume_high: {master_volume:.2f}")
muted_count = sum(1 for t in tracks if isinstance(t, dict) and t.get("mute", False))
if muted_count > track_count * 0.5:
issues.append(f"too_many_muted: {muted_count}/{track_count}")
if issues:
results.add_pass("export_readiness_issues", f"issues={len(issues)}")
for issue in issues:
results.add_fail(f"export_ready_{issue.split(':')[0]}", issue)
else:
results.add_pass("export_ready", "set appears ready for export")
except Exception as e:
results.add_fail("export_readiness_check", str(e))
def run_midi_clip_content_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify MIDI tracks have clips with notes."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
midi_tracks_empty = []
midi_tracks_with_notes = 0
for track in tracks:
if not isinstance(track, dict):
continue
track_type = str(track.get("type", "")).lower()
if track_type != "midi":
continue
track_name = track.get("name", "?")
clips = track.get("clips", [])
if not isinstance(clips, list):
clips = []
has_notes = False
empty_clips = []
for clip in clips:
if not isinstance(clip, dict):
continue
notes_count = clip.get("notes_count", 0)
has_notes_flag = clip.get("has_notes", None)
if has_notes_flag is True or notes_count > 0:
has_notes = True
elif has_notes_flag is False or (has_notes_flag is None and notes_count == 0):
empty_clips.append(clip.get("name", "?"))
if has_notes:
midi_tracks_with_notes += 1
elif empty_clips:
midi_tracks_empty.append({
"track_name": track_name,
"empty_clips_count": len(empty_clips),
})
if midi_tracks_with_notes > 0:
results.add_pass("midi_tracks_with_notes", f"count={midi_tracks_with_notes}")
if midi_tracks_empty:
for track_info in midi_tracks_empty[:3]:
results.add_fail(
f"midi_track_empty_{track_info['track_name'][:20]}",
f"Track has {track_info['empty_clips_count']} empty MIDI clips - may need notes"
)
except Exception as e:
results.add_fail("midi_clip_content_check", str(e))
def run_bus_signal_checks(client: AbletonSocketClient, results: TestResult) -> None:
"""Verify buses receive signal from tracks."""
try:
buses_payload = expect_success("list_buses", client.send("list_buses"))
buses = _extract_bus_payload(buses_payload)
tracks = expect_success("get_tracks", client.send("get_tracks"))
bus_signal_map = {}
for bus in buses:
if not isinstance(bus, dict):
continue
bus_name = bus.get("name", "").upper()
bus_signal_map[bus_name] = {"senders": [], "has_signal": False}
for track in tracks:
if not isinstance(track, dict):
continue
track_name = str(track.get("name", "")).upper()
output_routing = str(track.get("current_output_routing", "")).upper()
for bus_name in bus_signal_map:
if bus_name in output_routing:
bus_signal_map[bus_name]["senders"].append(track_name)
sends = track.get("sends", [])
if isinstance(sends, list):
for send_level in sends:
try:
if float(send_level) > 0.01:
pass
except (TypeError, ValueError):
pass
buses_without_senders = []
buses_with_senders = []
for bus_name, info in bus_signal_map.items():
if info["senders"]:
buses_with_senders.append(bus_name)
else:
buses_without_senders.append(bus_name)
if buses_with_senders:
results.add_pass("buses_with_signal", f"buses={buses_with_senders}")
if buses_without_senders:
for bus_name in buses_without_senders[:3]:
results.add_fail(f"bus_no_signal_{bus_name[:15]}",
f"Bus '{bus_name}' has no routed tracks - will not produce output")
except Exception as e:
results.add_fail("bus_signal_check", str(e))
def run_clipping_detection(client: AbletonSocketClient, results: TestResult) -> None:
"""Detect tracks with dangerously high volume (clipping risk)."""
try:
tracks = expect_success("get_tracks", client.send("get_tracks"))
clipping_tracks = []
high_volume_tracks = []
for track in tracks:
if not isinstance(track, dict):
continue
track_name = track.get("name", "?")
volume = float(track.get("volume", 0.85))
if volume > 0.95:
clipping_tracks.append({"name": track_name, "volume": volume})
elif volume > 0.90:
high_volume_tracks.append({"name": track_name, "volume": volume})
if clipping_tracks:
for track_info in clipping_tracks[:3]:
results.add_fail(f"clipping_track_{track_info['name'][:15]}",f"Volume {track_info['volume']:.2f} > 0.95 - CLIPPING RISK")
if high_volume_tracks:
for track_info in high_volume_tracks[:3]:
results.add_warning(f"high_volume_{track_info['name'][:15]}",
f"Volume {track_info['volume']:.2f} - consider reducing")
if not clipping_tracks and not high_volume_tracks:
results.add_pass("no_clipping_tracks", "All track volumes in safe range")
except Exception as e:
results.add_fail("clipping_detection", str(e))
def run_all_phase7_tests(client: AbletonSocketClient, results: TestResult) -> None:
"""Run all Phase 7 smoke tests."""
print("\n[Phase 7] Running bus verification...")
run_bus_checks(client, results)
print("[Phase 7] Running routing verification...")
run_routing_checks(client, results)
print("[Phase 7] Running AUDIO RESAMPLE track verification...")
run_audio_resample_checks(client, results)
print("[Phase 7] Running automation snapshot verification...")
run_automation_snapshot_checks(client, results)
print("[Phase 7] Running loudness verification...")
run_loudness_checks(client, results)
print("[Phase 7] Running critical layer verification...")
run_critical_layer_checks(client, results)
print("[Phase 7] Running derived FX verification...")
run_derived_fx_checks(client, results)
print("[Phase 7] Running export readiness verification...")
run_export_readiness_checks(client, results)
print("[Phase 7] Running MIDI clip content verification...")
run_midi_clip_content_checks(client, results)
print("[Phase 7] Running bus signal verification...")
run_bus_signal_checks(client, results)
print("[Phase 7] Running clipping detection...")
run_clipping_detection(client, results)
def main() -> int:
parser = argparse.ArgumentParser(description="Smoke test for AbletonMCP_AI socket runtime")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9877)
parser.add_argument("--timeout", type=float, default=15.0)
parser.add_argument("--generate-demo", action="store_true")
parser.add_argument("--genre", default="techno")
parser.add_argument("--style", default="industrial")
parser.add_argument("--bpm", type=float, default=128.0)
parser.add_argument("--key", default="Am")
parser.add_argument("--structure", default="standard")
parser.add_argument("--use-blueprint", action="store_true")
parser.add_argument("--phase7", action="store_true", help="Run Phase 7 extended tests (buses, routing, audio resample, automation, loudness)")
parser.add_argument("--json-report", action="store_true", help="Output report as JSON")
args = parser.parse_args()
client = AbletonSocketClient(host=args.host, port=args.port, timeout=args.timeout)
# Run basic checks
print("[Basic] Running readonly checks...")
checks = run_readonly_checks(client)
for name, details in checks:
print(f"[ok] {name}: {details}")
# Run generation check if requested
if args.generate_demo:
print("\n[Generation] Running generation check...")
checks.extend(
run_generation_check(
client,
genre=args.genre,
style=args.style,
bpm=args.bpm,
key=args.key,
structure=args.structure,
use_blueprint=args.use_blueprint,
)
)
for name, details in checks[-2:]:
print(f"[ok] {name}: {details}")
# Run Phase 7 tests if requested
results = TestResult()
if args.phase7:
run_all_phase7_tests(client, results)
if args.json_report:
print(json.dumps(results.to_dict(), indent=2))
else:
results.print_report()
return 0 if len(results.failed) == 0 else 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import argparse
import gzip
import json
from collections import Counter
from pathlib import Path
import xml.etree.ElementTree as ET
def _node_name(node: ET.Element | None) -> str:
if node is None:
return ""
for tag in ("EffectiveName", "UserName", "Name"):
child = node.find(tag)
if child is not None:
value = child.attrib.get("Value", "")
if value:
return value
return node.attrib.get("Value", "")
def _device_name(device: ET.Element) -> str:
if device.tag == "PluginDevice":
info = device.find("PluginDesc/VstPluginInfo")
if info is None:
info = device.find("PluginDesc/AuPluginInfo")
if info is not None:
plug = info.find("PlugName")
if plug is not None and plug.attrib.get("Value"):
return plug.attrib["Value"]
return device.tag
def _session_clip_count(track: ET.Element) -> int:
count = 0
for slot in track.findall("./DeviceChain/MainSequencer/ClipSlotList/ClipSlot"):
if slot.find("Value/MidiClip") is not None or slot.find("Value/AudioClip") is not None:
count += 1
return count
def _arrangement_clip_count(track: ET.Element) -> int:
return len(track.findall(".//MainSequencer//MidiClip")) + len(
track.findall(".//MainSequencer//AudioClip")
)
def _tempo_value(live_set: ET.Element) -> float | None:
node = live_set.find(".//Tempo/Manual")
if node is None:
return None
try:
return float(node.attrib.get("Value", "0"))
except ValueError:
return None
def _locator_summary(live_set: ET.Element) -> list[dict[str, float | str | None]]:
locators: list[tuple[float, str]] = []
for locator in live_set.findall(".//Locators/Locators/Locator"):
try:
time = float(locator.find("Time").attrib.get("Value", "0"))
except (AttributeError, ValueError):
time = 0.0
name = _node_name(locator.find("Name"))
locators.append((time, name))
locators.sort(key=lambda item: item[0])
summary: list[dict[str, float | str | None]] = []
for index, (time, name) in enumerate(locators):
next_time = locators[index + 1][0] if index + 1 < len(locators) else None
summary.append(
{
"time_beats": time,
"name": name,
"section_length_beats": None if next_time is None else next_time - time,
}
)
return summary
def _arrangement_length_beats(root: ET.Element) -> float:
max_end = 0.0
for clip in root.findall(".//MidiClip") + root.findall(".//AudioClip"):
current_end = clip.find("CurrentEnd")
start = clip.attrib.get("Time")
if current_end is None or start is None:
continue
try:
end = float(start) + float(current_end.attrib.get("Value", "0"))
except ValueError:
continue
max_end = max(max_end, end)
return max_end
def analyze_set(als_path: Path) -> dict:
with gzip.open(als_path, "rb") as handle:
root = ET.parse(handle).getroot()
live_set = root.find("LiveSet")
if live_set is None:
raise ValueError(f"Invalid ALS file: {als_path}")
tracks = list(live_set.find("Tracks") or [])
track_summaries = []
device_counter: Counter[str] = Counter()
for track in tracks:
devices = track.findall("./DeviceChain/DeviceChain/Devices/*")
device_names = [_device_name(device) for device in devices]
device_counter.update(device_names)
track_summaries.append(
{
"type": track.tag,
"name": _node_name(track.find("Name")),
"group_id": track.find("TrackGroupId").attrib.get("Value", "")
if track.find("TrackGroupId") is not None
else "",
"session_clip_count": _session_clip_count(track),
"arrangement_clip_count": _arrangement_clip_count(track),
"devices": device_names,
}
)
automation_events = 0
for automation in root.findall(".//ArrangerAutomation"):
automation_events += len(automation.findall(".//FloatEvent"))
automation_events += len(automation.findall(".//EnumEvent"))
automation_events += len(automation.findall(".//BoolEvent"))
return {
"file": str(als_path),
"tempo": _tempo_value(live_set),
"track_type_counts": dict(Counter(track.tag for track in tracks)),
"scene_count": len(live_set.findall("./SceneNames/Scene")),
"locators": _locator_summary(live_set),
"arrangement_length_beats": _arrangement_length_beats(root),
"automation_event_count": automation_events,
"top_devices": dict(device_counter.most_common(16)),
"tracks": track_summaries,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Analyze Ableton .als templates.")
parser.add_argument("path", nargs="?", default=".", help="Folder containing .als files")
parser.add_argument("--json", action="store_true", help="Emit JSON")
args = parser.parse_args()
base = Path(args.path).resolve()
results = [analyze_set(path) for path in sorted(base.rglob("*.als"))]
if args.json:
print(json.dumps(results, indent=2))
return
for result in results:
print(f"=== {Path(result['file']).name} ===")
print(f"tempo: {result['tempo']}")
print(f"tracks: {result['track_type_counts']}")
print(f"scenes: {result['scene_count']}")
print(f"arrangement_length_beats: {result['arrangement_length_beats']}")
print(f"automation_event_count: {result['automation_event_count']}")
print("locators:")
for locator in result["locators"]:
print(
f" - {locator['time_beats']:>6} {locator['name']}"
f" len={locator['section_length_beats']}"
)
print("top_devices:")
for name, count in result["top_devices"].items():
print(f" - {name}: {count}")
print()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,452 @@
import os
import json
import logging
import argparse
from pathlib import Path
from typing import List, Dict, Tuple, Optional
from multiprocessing import Pool, cpu_count
import functools
try:
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
HAS_ML = True
except ImportError:
HAS_ML = False
# Import AudioAnalyzer for spectral analysis
try:
from audio_analyzer import AudioAnalyzer, analyze_sample
HAS_AUDIO_ANALYZER = True
except ImportError:
HAS_AUDIO_ANALYZER = False
logger = logging.getLogger("VectorManager")
logging.basicConfig(level=logging.INFO)
# Global analyzer for multiprocessing workers (initialized once per worker)
_worker_analyzer = None
def _init_worker():
"""Initialize the audio analyzer for each worker process."""
global _worker_analyzer
if HAS_AUDIO_ANALYZER:
try:
_worker_analyzer = AudioAnalyzer(backend="auto")
except Exception:
_worker_analyzer = None
def _process_single_file(args):
"""
Process a single audio file and return its metadata.
Used for multiprocessing parallel execution.
"""
f, library_dir, skip_audio_analysis = args
f = Path(f)
import soundfile as sf
# Clean up the name for better semantic understanding
name = f.stem
name_lower = name.lower()
clean_name = name.replace('_', ' ').replace('-', ' ').lower()
# Keywords that strongly suggest a full song/mix
full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'}
# Extract duration
duration = 0.0
try:
info = sf.info(str(f))
duration = info.duration
except Exception:
duration = -1.0
# Detect if it's likely a full song based on name and duration
is_full_song = False
if duration > 45.0:
is_full_song = True
elif any(kw in name_lower for kw in full_song_keywords) and duration > 30.0:
is_full_song = True
# Spectral analysis with AudioAnalyzer
key = None
key_confidence = 0.0
spectral_centroid = None
is_harmonic = None
global _worker_analyzer
if not skip_audio_analysis and _worker_analyzer is not None:
try:
features = _worker_analyzer.analyze(str(f))
key = features.key
key_confidence = features.key_confidence
spectral_centroid = features.spectral_centroid
is_harmonic = features.is_harmonic
except Exception:
pass
# Use relative path as part of the context
try:
rel_path = f.relative_to(library_dir)
parts = rel_path.parts[:-1]
path_context = " ".join(parts).lower()
except ValueError:
path_context = ""
description = f"{clean_name} {path_context}"
metadata = {
'path': str(f),
'name': name,
'description': description,
'duration': duration,
'is_full_song': is_full_song,
'key': key,
'key_confidence': key_confidence,
'spectral_centroid': spectral_centroid,
'is_harmonic': is_harmonic
}
return metadata, description
class VectorManager:
def __init__(self, library_dir: str, skip_audio_analysis: bool = False):
self.library_dir = Path(library_dir)
self.index_file = self.library_dir / ".sample_embeddings.json"
self.skip_audio_analysis = skip_audio_analysis
self.model = None
self.embeddings = []
self.metadata = []
# Audio analyzer instance for spectral analysis
self._audio_analyzer: Optional[AudioAnalyzer] = None
if HAS_AUDIO_ANALYZER and not skip_audio_analysis:
try:
self._audio_analyzer = AudioAnalyzer(backend="auto")
logger.info("AudioAnalyzer initialized for spectral analysis")
except Exception as e:
logger.warning(f"Failed to initialize AudioAnalyzer: {e}")
self._audio_analyzer = None
if HAS_ML:
try:
# Load a very lightweight model for fast embeddings
logger.info("Loading sentence-transformers model (all-MiniLM-L6-v2)...")
self.model = SentenceTransformer('all-MiniLM-L6-v2')
except Exception as e:
logger.error(f"Failed to load embedding model: {e}")
self._load_or_build_index()
def _get_library_fingerprint(self) -> Dict:
"""Compute a fingerprint of the library directory for change detection (BF-02/MJ-07)."""
extensions = {'.wav', '.aif', '.aiff', '.mp3'}
file_count = 0
latest_mtime = 0.0
try:
for ext in extensions:
for f in self.library_dir.rglob('*' + ext):
file_count += 1
try:
mtime = f.stat().st_mtime
if mtime > latest_mtime:
latest_mtime = mtime
except OSError:
pass
for f in self.library_dir.rglob('*' + ext.upper()):
file_count += 1
try:
mtime = f.stat().st_mtime
if mtime > latest_mtime:
latest_mtime = mtime
except OSError:
pass
except Exception:
pass
return {'file_count': file_count, 'latest_mtime': latest_mtime}
def _load_or_build_index(self):
if self.index_file.exists():
logger.info("Loading existing vector index...")
try:
with open(self.index_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.metadata = data.get('metadata', [])
# BF-02/MJ-07: Check library fingerprint for auto-rebuild
stored_fp = data.get('library_fingerprint', {})
current_fp = self._get_library_fingerprint()
stored_count = stored_fp.get('file_count', 0)
current_count = current_fp.get('file_count', 0)
if current_count != stored_count and stored_count > 0:
logger.info(f"Library changed ({stored_count} -> {current_count} files). Rebuilding index...")
self._build_index()
return
if HAS_ML and 'embeddings' in data:
self.embeddings = np.array(data['embeddings'])
else:
logger.warning("No embeddings found in loaded index.")
except Exception as e:
logger.error(f"Failed to load index: {e}")
self._build_index()
else:
self._build_index()
def _build_index(self):
logger.info(f"Scanning library {self.library_dir} for new embeddings...")
extensions = {'.wav', '.aif', '.aiff', '.mp3'}
files_to_process = []
for ext in extensions:
files_to_process.extend(self.library_dir.rglob('*' + ext))
files_to_process.extend(self.library_dir.rglob('*' + ext.upper()))
if not files_to_process:
logger.warning(f"No audio files found in {self.library_dir} to embed.")
return
# Get unique files
unique_files = list(set(str(f) for f in files_to_process))
total_files = len(unique_files)
logger.info(f"Found {total_files} audio files to process")
# Determine number of workers (use 50% of available CPUs)
num_workers = max(1, cpu_count() // 2)
logger.info(f"Using {num_workers} CPU cores for parallel processing (50% capacity)")
# Prepare arguments for parallel processing
args_list = [(f, str(self.library_dir), self.skip_audio_analysis) for f in unique_files]
# Process files in parallel using multiprocessing
texts_to_embed = []
self.metadata = []
if not self.skip_audio_analysis and HAS_AUDIO_ANALYZER:
# Use multiprocessing with audio analysis
logger.info("Starting parallel audio analysis...")
with Pool(processes=num_workers, initializer=_init_worker) as pool:
results = pool.map(_process_single_file, args_list)
for metadata, description in results:
self.metadata.append(metadata)
texts_to_embed.append(description)
else:
# Fallback to sequential processing (no audio analysis)
logger.info("Processing files sequentially (audio analysis disabled)...")
import soundfile as sf
full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'}
for i, f in enumerate(unique_files):
f = Path(f)
if (i + 1) % max(1, total_files // 20) == 0 or (i + 1) == total_files:
logger.info(f"Processing files: {i+1}/{total_files} ({(i+1)/total_files*100:.1f}%)")
name = f.stem
clean_name = name.replace('_', ' ').replace('-', ' ').lower()
duration = 0.0
try:
info = sf.info(str(f))
duration = info.duration
except Exception:
duration = -1.0
is_full_song = duration > 45.0
try:
rel_path = f.relative_to(self.library_dir)
path_context = " ".join(rel_path.parts[:-1]).lower()
except ValueError:
path_context = ""
description = f"{clean_name} {path_context}"
texts_to_embed.append(description)
self.metadata.append({
'path': str(f),
'name': name,
'description': description,
'duration': duration,
'is_full_song': is_full_song,
'key': None,
'key_confidence': 0.0,
'spectral_centroid': None,
'is_harmonic': None
})
if HAS_ML and self.model:
logger.info(f"Generating vectors for {len(texts_to_embed)} samples. This might take a moment...")
embeddings = self.model.encode(texts_to_embed)
self.embeddings = embeddings
# BF-02: Save fingerprint alongside embeddings for auto-rebuild detection
fingerprint = self._get_library_fingerprint()
# Save the vectors
with open(self.index_file, 'w', encoding='utf-8') as f:
json.dump({
'metadata': self.metadata,
'embeddings': embeddings.tolist(),
'library_fingerprint': fingerprint
}, f)
logger.info(f"Saved {len(self.metadata)} embeddings to {self.index_file}.")
else:
logger.error("ML libraries not installed. Run 'pip install sentence-transformers scikit-learn numpy'")
# MJ-06: Genre keyword expansion for richer semantic search
GENRE_SEARCH_TERMS = {
'tech-house': ['groovy', 'driving', 'punchy', 'jackin', 'swinging', 'hypnotic', 'bouncy'],
'house': ['deep', 'soulful', 'warm', 'classic', 'funky'],
'techno': ['industrial', 'dark', 'raw', 'hypnotic', 'peak-time', 'acid'],
'trance': ['uplifting', 'ethereal', 'driving', 'euphoric'],
'deep-house': ['deep', 'chill', 'smooth', 'laidback', 'warm'],
'minimal': ['minimal', 'sparse', 'subtle', 'clean'],
'drum-and-bass': ['heavy', 'dark', 'neuro', 'rolling', 'aggressive'],
}
def enrich_query_with_genre(self, query: str, genre: str = "") -> str:
"""MJ-06: Enrich a search query with genre-specific terms."""
genre_lower = (genre or "").lower().strip()
terms = self.GENRE_SEARCH_TERMS.get(genre_lower, [])
if terms:
# Pick 2 random genre terms to enrich without overwhelming
import random as _rng
picked = _rng.sample(terms, min(2, len(terms)))
enriched = f"{query} {' '.join(picked)}"
logger.info(f"Enriched query for '{genre_lower}': '{query}' -> '{enriched}'")
return enriched
return query
def semantic_search(self, query: str, limit: int = 5, max_duration: float = 0.0, genre: str = "") -> List[Dict]:
"""
Returns a list of metadata dicts sorted by semantic relevance down to the limit.
Fallback to basic substring matching if ML is unavailable.
Args:
query: Semantic search terms
limit: Max results to return
max_duration: If > 0, filter out samples longer than this value
genre: Optional genre to enrich the search query (MJ-06)
"""
if not HAS_ML or self.model is None or len(self.embeddings) == 0:
logger.warning("ML unavailable, falling back to substring search.")
return self._fallback_search(query, limit, max_duration)
# MJ-06: Enrich query with genre terms
effective_query = self.enrich_query_with_genre(query, genre) if genre else query
logger.info(f"Performing semantic search for: '{effective_query}' (max_duration={max_duration})")
query_emb = self.model.encode([effective_query])
# Calculate cosine similarity between query and all stored embeddings
similarities = cosine_similarity(query_emb, self.embeddings)[0]
# Apply duration and full-song penalties/filtering
adjusted_similarities = similarities.copy()
for i, meta in enumerate(self.metadata):
# Filter out if it exceeds max_duration (if specified)
if max_duration > 0 and (meta.get('duration', 0) > max_duration or meta.get('duration', 0) < 0):
adjusted_similarities[i] = -1.0
continue
# Filter out explicit full songs
if meta.get('is_full_song', False) and max_duration > 0:
adjusted_similarities[i] = -1.0
continue
# Small penalty for longer samples if no max_duration specified
# to prioritize snippets over loops
if max_duration == 0 and meta.get('duration', 0) > 10.0:
adjusted_similarities[i] *= 0.9
# Get top indices from adjusted scores
top_indices = np.argsort(adjusted_similarities)[::-1][:limit]
results = []
for idx in top_indices:
score = float(adjusted_similarities[idx])
if score < 0: # All remaining candidates are invalid
break
meta = self.metadata[idx].copy()
meta['score'] = score
results.append(meta)
return results
def _fallback_search(self, query: str, limit: int = 5, max_duration: float = 0.0) -> List[Dict]:
query = query.lower()
scored = []
for m in self.metadata:
# Duration filter
if max_duration > 0 and (m.get('duration', 0) > max_duration or m.get('duration', 0) < 0):
continue
if m.get('is_full_song', False) and max_duration > 0:
continue
score = 0
if query in m['name'].lower():
score += 10
if query in m['description'].lower():
score += 5
if score > 0:
scored.append((score, m))
scored.sort(key=lambda x: x[0], reverse=True)
return [m for s, m in scored[:limit]]
if __name__ == "__main__":
import sys
import argparse
parser = argparse.ArgumentParser(description="Vector Manager for sample library indexing")
parser.add_argument("library_dir", nargs='?', help="Path to the sample library directory")
parser.add_argument("search_query", nargs='?', help="Optional search query to test")
parser.add_argument("--skip-audio-analysis", action="store_true",
help="Skip spectral audio analysis for faster rebuild (development mode)")
parser.add_argument("--rebuild", action="store_true",
help="Force rebuild of the index from scratch")
args = parser.parse_args()
if args.library_dir:
# Check if index exists and rebuild flag is set
index_file = Path(args.library_dir) / ".sample_embeddings.json"
if args.rebuild and index_file.exists():
logger.info(f"Removing existing index for rebuild: {index_file}")
index_file.unlink()
vm = VectorManager(args.library_dir, skip_audio_analysis=args.skip_audio_analysis)
if args.search_query:
res = vm.semantic_search(args.search_query)
print(f"Search Results for '{args.search_query}':")
for r in res:
print(f" Score: {r['score']:.3f}")
print(f" Name: {r['name']}")
print(f" Path: {r['path']}")
print(f" Key: {r.get('key', 'N/A')} (confidence: {r.get('key_confidence', 0):.2f})")
print(f" Spectral Centroid: {r.get('spectral_centroid', 'N/A')}")
print(f" Is Harmonic: {r.get('is_harmonic', 'N/A')}")
print()
else:
# Print summary of the loaded index
print(f"\nIndex Summary:")
print(f" Total samples: {len(vm.metadata)}")
# Count samples with spectral data
with_key = sum(1 for m in vm.metadata if m.get('key') is not None)
with_centroid = sum(1 for m in vm.metadata if m.get('spectral_centroid') is not None)
print(f" Samples with key detected: {with_key}")
print(f" Samples with spectral centroid: {with_centroid}")
else:
print("Usage: python vector_manager.py <library_dir> [search_query] [--skip-audio-analysis] [--rebuild]")
print("\nOptions:")
print(" --skip-audio-analysis Skip spectral analysis for faster rebuild")
print(" --rebuild Force rebuild index from scratch")