Files
ableton-mcp-ai/mcp_server/engines/reference_matcher.py
OpenCode Agent 5ce8187c65 feat: Implement senior audio injection with 5 fallback methods
- Add _cmd_create_arrangement_audio_pattern with 5-method fallback chain
- Method 1: track.insert_arrangement_clip() [Live 12+]
- Method 2: track.create_audio_clip() [Live 11+]
- Method 3: arrangement_clips.add_new_clip() [Live 12+]
- Method 4: Session->duplicate_clip_to_arrangement [Legacy]
- Method 5: Session->Recording [Universal]

- Add _cmd_duplicate_clip_to_arrangement for session-to-arrangement workflow
- Update skills documentation
- Verified: 3 clips created at positions [0, 4, 8] in Arrangement View

Closes: Audio injection in Arrangement View
2026-04-12 14:02:32 -03:00

923 lines
33 KiB
Python

"""
Reference Matcher - Analyzes reference tracks and creates user sound profiles.
Este módulo analiza archivos de referencia (como reggaeton_ejemplo.mp3),
extrae sus características espectrales y genera un perfil de sonido
personalizado para el usuario basado en samples similares de la librería.
"""
import json
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
from dataclasses import dataclass, field, asdict
import numpy as np
from collections import Counter
logger = logging.getLogger("ReferenceMatcher")
# Paths
LIBRERIA_DIR = Path(r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\libreria")
REGGAETON_DIR = LIBRERIA_DIR / "reggaeton"
REFERENCE_FILE = LIBRERIA_DIR / "reggaeton_ejemplo.mp3"
PROFILE_FILE = REGGAETON_DIR / ".user_sound_profile.json"
# Roles de samples soportados
SAMPLE_ROLES = ["kick", "snare", "clap", "hat_closed", "hat_open",
"bass", "synth", "fx", "perc", "drum_loop"]
@dataclass
class SpectralFingerprint:
"""Fingerprint espectral completo de un audio."""
bpm: float = 0.0
key: str = ""
energy_curve: List[float] = field(default_factory=list)
mfccs_mean: List[float] = field(default_factory=list)
spectral_centroid_mean: float = 0.0
onset_strength_mean: float = 0.0
duration: float = 0.0
sample_rate: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"bpm": self.bpm,
"key": self.key,
"energy_curve": self.energy_curve,
"mfccs_mean": self.mfccs_mean,
"spectral_centroid_mean": self.spectral_centroid_mean,
"onset_strength_mean": self.onset_strength_mean,
"duration": self.duration,
"sample_rate": self.sample_rate
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpectralFingerprint":
return cls(
bpm=data.get("bpm", 0.0),
key=data.get("key", ""),
energy_curve=data.get("energy_curve", []),
mfccs_mean=data.get("mfccs_mean", []),
spectral_centroid_mean=data.get("spectral_centroid_mean", 0.0),
onset_strength_mean=data.get("onset_strength_mean", 0.0),
duration=data.get("duration", 0.0),
sample_rate=data.get("sample_rate", 0)
)
@dataclass
class SampleMatch:
"""Resultado de comparación de un sample contra referencia."""
path: str
name: str
role: str
similarity_score: float
fingerprint: SpectralFingerprint
@dataclass
class UserSoundProfile:
"""Perfil de sonido personalizado del usuario."""
# Características promedio ponderadas
preferred_bpm: float = 0.0
preferred_key: str = ""
preferred_timbre: List[float] = field(default_factory=list)
characteristic_energy_curve: List[float] = field(default_factory=list)
# Roles más usados (ordenados por frecuencia)
preferred_roles: List[str] = field(default_factory=list)
# Metadata
created_from_reference: str = ""
total_matches_analyzed: int = 0
genre: str = "reggaeton"
# Matches más similares por rol
top_matches_by_role: Dict[str, List[Dict]] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"preferred_bpm": self.preferred_bpm,
"preferred_key": self.preferred_key,
"preferred_timbre": self.preferred_timbre,
"characteristic_energy_curve": self.characteristic_energy_curve,
"preferred_roles": self.preferred_roles,
"created_from_reference": self.created_from_reference,
"total_matches_analyzed": self.total_matches_analyzed,
"genre": self.genre,
"top_matches_by_role": self.top_matches_by_role
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "UserSoundProfile":
return cls(
preferred_bpm=data.get("preferred_bpm", 0.0),
preferred_key=data.get("preferred_key", ""),
preferred_timbre=data.get("preferred_timbre", []),
characteristic_energy_curve=data.get("characteristic_energy_curve", []),
preferred_roles=data.get("preferred_roles", []),
created_from_reference=data.get("created_from_reference", ""),
total_matches_analyzed=data.get("total_matches_analyzed", 0),
genre=data.get("genre", "reggaeton"),
top_matches_by_role=data.get("top_matches_by_role", {})
)
class AudioAnalyzer:
"""Analiza archivos de audio y extrae fingerprints espectrales."""
def __init__(self):
self._librosa_available = self._check_librosa()
def _check_librosa(self) -> bool:
"""Verifica si librosa está disponible."""
try:
import librosa
import librosa.display
return True
except ImportError:
logger.warning("librosa no disponible. Usando modo simulado.")
return False
def analyze_file(self, file_path: str) -> Optional[SpectralFingerprint]:
"""
Analiza un archivo de audio y extrae su fingerprint espectral.
Args:
file_path: Ruta al archivo de audio
Returns:
SpectralFingerprint con todas las características extraídas
"""
if not os.path.exists(file_path):
logger.error("Archivo no encontrado: %s", file_path)
return None
if self._librosa_available:
return self._analyze_with_librosa(file_path)
else:
return self._generate_mock_fingerprint(file_path)
def _analyze_with_librosa(self, file_path: str) -> Optional[SpectralFingerprint]:
"""Análisis real usando librosa."""
try:
import librosa
import librosa.display
# Cargar audio
y, sr = librosa.load(file_path, sr=None)
duration = librosa.get_duration(y=y, sr=sr)
# 1. Detectar BPM
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
bpm = float(tempo) if isinstance(tempo, (int, float, np.number)) else 95.0
# 2. Detectar Key (simplificado - usa chroma)
chroma = librosa.feature.chroma_stft(y=y, sr=sr)
chroma_mean = np.mean(chroma, axis=1)
key_idx = np.argmax(chroma_mean)
keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
key = keys[key_idx] + "m" # Asumimos menor para reggaeton
# 3. Energy curve (RMS por segmentos de 1 segundo)
hop_length = 512
frame_length = sr # 1 segundo
rms = librosa.feature.rms(y=y, frame_length=frame_length, hop_length=hop_length)[0]
energy_curve = rms.tolist() if len(rms) > 0 else [0.5]
# Normalizar a 16 segmentos máximo
if len(energy_curve) > 16:
# Agrupar en 16 segmentos
segment_size = len(energy_curve) // 16
energy_curve = [
np.mean(energy_curve[i:i+segment_size])
for i in range(0, len(energy_curve), segment_size)
][:16]
# 4. MFCCs (timbre) - promedio
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
mfccs_mean = np.mean(mfccs, axis=1).tolist()
# 5. Spectral centroid (brillo)
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_centroid_mean = float(np.mean(spectral_centroids))
# 6. Onset strength (ritmo/percussividad)
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
onset_strength_mean = float(np.mean(onset_env))
logger.info("Análisis completado: %s (BPM: %.1f, Key: %s)",
file_path, bpm, key)
return SpectralFingerprint(
bpm=bpm,
key=key,
energy_curve=energy_curve,
mfccs_mean=mfccs_mean,
spectral_centroid_mean=spectral_centroid_mean,
onset_strength_mean=onset_strength_mean,
duration=duration,
sample_rate=sr
)
except Exception as e:
logger.error("Error analizando %s: %s", file_path, e)
return self._generate_mock_fingerprint(file_path)
def _generate_mock_fingerprint(self, file_path: str) -> SpectralFingerprint:
"""Genera fingerprint simulado para pruebas sin librosa."""
import hashlib
# Generar valores deterministas basados en el nombre del archivo
name_hash = hashlib.md5(file_path.encode()).hexdigest()
# BPM entre 85-105 (típico reggaeton)
bpm = 85 + (int(name_hash[:4], 16) % 20)
# Key basada en hash
keys = ['Am', 'Dm', 'Gm', 'Cm', 'Em', 'Bm', 'Fm']
key = keys[int(name_hash[4:6], 16) % len(keys)]
# Energy curve simulado (16 segmentos)
np.random.seed(int(name_hash[:8], 16))
energy_curve = np.random.uniform(0.3, 0.9, 16).tolist()
# MFCCs simulados
mfccs_mean = np.random.uniform(-50, 50, 13).tolist()
return SpectralFingerprint(
bpm=float(bpm),
key=key,
energy_curve=energy_curve,
mfccs_mean=mfccs_mean,
spectral_centroid_mean=float(2000 + int(name_hash[6:10], 16) % 2000),
onset_strength_mean=float(0.3 + (int(name_hash[10:12], 16) % 70) / 100),
duration=30.0,
sample_rate=44100
)
class SimilarityEngine:
"""Calcula similitud entre fingerprints espectrales."""
def find_similar(self,
reference: SpectralFingerprint,
candidates: List[Tuple[str, SpectralFingerprint]],
top_k: int = 20) -> List[SampleMatch]:
"""
Encuentra los samples más similares a la referencia.
Args:
reference: Fingerprint de referencia
candidates: Lista de (path, fingerprint) a comparar
top_k: Número de resultados a retornar
Returns:
Lista de SampleMatch ordenados por similitud
"""
matches = []
for path, candidate_fp in candidates:
score = self._calculate_similarity(reference, candidate_fp)
# Determinar rol basado en path
role = self._guess_role_from_path(path)
name = os.path.basename(path)
matches.append(SampleMatch(
path=path,
name=name,
role=role,
similarity_score=score,
fingerprint=candidate_fp
))
# Ordenar por score descendente
matches.sort(key=lambda x: x.similarity_score, reverse=True)
return matches[:top_k]
def _calculate_similarity(self,
ref: SpectralFingerprint,
cand: SpectralFingerprint) -> float:
"""
Calcula score de similitud entre dos fingerprints.
Retorna valor entre 0.0 y 1.0.
"""
scores = []
weights = []
# 1. Similitud de BPM (weight: 0.25)
if ref.bpm > 0 and cand.bpm > 0:
bpm_diff = abs(ref.bpm - cand.bpm)
bpm_sim = max(0, 1 - (bpm_diff / 30)) # 30 BPM de tolerancia
scores.append(bpm_sim)
weights.append(0.25)
# 2. Similitud de Key (weight: 0.15)
if ref.key and cand.key:
key_sim = 1.0 if ref.key == cand.key else 0.5 if ref.key[0] == cand.key[0] else 0.0
scores.append(key_sim)
weights.append(0.15)
# 3. Similitud de Energy Curve (weight: 0.25)
if ref.energy_curve and cand.energy_curve:
# Interpolar a mismo tamaño
min_len = min(len(ref.energy_curve), len(cand.energy_curve))
ref_curve = np.array(ref.energy_curve[:min_len])
cand_curve = np.array(cand.energy_curve[:min_len])
# Correlación de Pearson
if len(ref_curve) > 1:
corr = np.corrcoef(ref_curve, cand_curve)[0, 1]
if not np.isnan(corr):
energy_sim = (corr + 1) / 2 # Normalizar a 0-1
scores.append(energy_sim)
weights.append(0.25)
# 4. Similitud de Timbre (MFCCs) (weight: 0.20)
if ref.mfccs_mean and cand.mfccs_mean:
ref_mfccs = np.array(ref.mfccs_mean)
cand_mfccs = np.array(cand.mfccs_mean)
# Distancia euclidiana normalizada
distance = np.linalg.norm(ref_mfccs - cand_mfccs)
max_dist = np.linalg.norm(np.abs(ref_mfccs) + 100) # Estimación de max
timbre_sim = max(0, 1 - (distance / max_dist))
scores.append(timbre_sim)
weights.append(0.20)
# 5. Similitud de Spectral Centroid (weight: 0.10)
if ref.spectral_centroid_mean > 0 and cand.spectral_centroid_mean > 0:
sc_diff = abs(ref.spectral_centroid_mean - cand.spectral_centroid_mean)
sc_max = max(ref.spectral_centroid_mean, cand.spectral_centroid_mean)
sc_sim = max(0, 1 - (sc_diff / sc_max)) if sc_max > 0 else 0.5
scores.append(sc_sim)
weights.append(0.10)
# 6. Similitud de Onset Strength (weight: 0.05)
if ref.onset_strength_mean > 0 and cand.onset_strength_mean > 0:
os_diff = abs(ref.onset_strength_mean - cand.onset_strength_mean)
os_max = max(ref.onset_strength_mean, cand.onset_strength_mean)
os_sim = max(0, 1 - (os_diff / os_max)) if os_max > 0 else 0.5
scores.append(os_sim)
weights.append(0.05)
# Calcular promedio ponderado
if not scores:
return 0.5
total_weight = sum(weights)
weighted_score = sum(s * w for s, w in zip(scores, weights)) / total_weight
return float(weighted_score)
def _guess_role_from_path(self, path: str) -> str:
"""Infiere el rol del sample basado en su path."""
lower = path.lower()
if "kick" in lower:
return "kick"
if "snare" in lower:
return "snare"
if "clap" in lower:
return "clap"
if "hi-hat" in lower or "hihat" in lower:
return "hat_closed"
if "bass" in lower:
return "bass"
if "fx" in lower:
return "fx"
if "perc" in lower:
return "perc"
if "drumloop" in lower or "drum_loop" in lower:
return "drum_loop"
if "oneshot" in lower or "synth" in lower:
return "synth"
return "synth" # Default
class ReferenceMatcher:
"""
Matcher principal que analiza referencias y genera perfiles de usuario.
"""
def __init__(self,
reference_path: Optional[str] = None,
library_path: Optional[str] = None,
profile_path: Optional[str] = None):
self.reference_path = reference_path or str(REFERENCE_FILE)
self.library_path = library_path or str(REGGAETON_DIR)
self.profile_path = profile_path or str(PROFILE_FILE)
self.analyzer = AudioAnalyzer()
self.similarity = SimilarityEngine()
self._reference_fingerprint: Optional[SpectralFingerprint] = None
self._library_index: List[Tuple[str, SpectralFingerprint]] = []
self._profile: Optional[UserSoundProfile] = None
def analyze_reference(self) -> Optional[SpectralFingerprint]:
"""
Analiza el archivo de referencia y retorna su fingerprint.
Returns:
SpectralFingerprint del archivo de referencia
"""
logger.info("Analizando referencia: %s", self.reference_path)
self._reference_fingerprint = self.analyzer.analyze_file(self.reference_path)
if self._reference_fingerprint:
logger.info("Referencia analizada - BPM: %.1f, Key: %s",
self._reference_fingerprint.bpm,
self._reference_fingerprint.key)
return self._reference_fingerprint
def index_library(self, force_reindex: bool = False) -> List[Tuple[str, SpectralFingerprint]]:
"""
Indexa toda la librería y extrae fingerprints.
Args:
force_reindex: Si True, reindexa aunque ya exista índice
Returns:
Lista de (path, fingerprint) de todos los samples
"""
if self._library_index and not force_reindex:
return self._library_index
logger.info("Indexando librería: %s", self.library_path)
self._library_index = []
library = Path(self.library_path)
if not library.is_dir():
logger.error("Librería no encontrada: %s", self.library_path)
return []
audio_extensions = ('.wav', '.aif', '.aiff', '.mp3', '.flac', '.ogg')
for root, _dirs, files in os.walk(library):
for filename in files:
if filename.lower().endswith(audio_extensions):
filepath = os.path.join(root, filename)
# Analizar sample
fingerprint = self.analyzer.analyze_file(filepath)
if fingerprint:
self._library_index.append((filepath, fingerprint))
logger.debug("Indexado: %s", filename)
logger.info("Librería indexada: %d samples", len(self._library_index))
return self._library_index
def find_similar_samples(self,
top_k: int = 50,
role_filter: Optional[str] = None) -> List[SampleMatch]:
"""
Encuentra los samples más similares a la referencia.
Args:
top_k: Número de samples a retornar
role_filter: Si se especifica, filtra por rol específico
Returns:
Lista de SampleMatch ordenados por similitud
"""
if not self._reference_fingerprint:
self.analyze_reference()
if not self._library_index:
self.index_library()
if not self._reference_fingerprint or not self._library_index:
logger.error("No se puede buscar similares: falta referencia o librería")
return []
# Filtrar por rol si es necesario
candidates = self._library_index
if role_filter:
candidates = [
(path, fp) for path, fp in candidates
if self.similarity._guess_role_from_path(path) == role_filter
]
logger.info("Buscando %d samples similares (filtro: %s)...",
top_k, role_filter or "ninguno")
matches = self.similarity.find_similar(
self._reference_fingerprint,
candidates,
top_k=top_k
)
return matches
def generate_user_profile(self,
top_matches_count: int = 100,
save: bool = True) -> UserSoundProfile:
"""
Genera el perfil de sonido del usuario basado en matches similares.
Args:
top_matches_count: Cuántos matches usar para el perfil
save: Si True, guarda el perfil en disco
Returns:
UserSoundProfile generado
"""
logger.info("Generando perfil de usuario...")
# Obtener matches
matches = self.find_similar_samples(top_k=top_matches_count)
if not matches:
logger.warning("No hay matches para generar perfil")
return UserSoundProfile()
# Calcular BPM preferido (promedio ponderado por similitud)
total_weight = sum(m.similarity_score for m in matches)
weighted_bpm = sum(m.fingerprint.bpm * m.similarity_score
for m in matches if m.fingerprint.bpm > 0)
preferred_bpm = weighted_bpm / total_weight if total_weight > 0 else 95.0
# Calcular Key preferida (moda)
keys = [m.fingerprint.key for m in matches if m.fingerprint.key]
preferred_key = Counter(keys).most_common(1)[0][0] if keys else "Am"
# Calcular Timbre promedio (MFCCs ponderados)
mfccs_list = []
weights = []
for m in matches:
if m.fingerprint.mfccs_mean:
mfccs_list.append(np.array(m.fingerprint.mfccs_mean))
weights.append(m.similarity_score)
if mfccs_list and weights:
weighted_mfccs = np.average(mfccs_list, axis=0, weights=weights)
preferred_timbre = weighted_mfccs.tolist()
else:
preferred_timbre = []
# Energy curve característico (promedio de los matches)
energy_curves = []
for m in matches:
if m.fingerprint.energy_curve:
energy_curves.append(np.array(m.fingerprint.energy_curve))
if energy_curves:
# Interpolar todos a 16 segmentos
interpolated = []
for ec in energy_curves:
if len(ec) < 16:
# Replicar para llegar a 16
repeated = np.repeat(ec, 16 // len(ec) + 1)[:16]
interpolated.append(repeated)
else:
interpolated.append(ec[:16])
char_energy_curve = np.mean(interpolated, axis=0).tolist()
else:
char_energy_curve = [0.5] * 16
# Roles más usados
role_counts = Counter(m.role for m in matches)
preferred_roles = [role for role, _ in role_counts.most_common()]
# Top matches por rol
top_by_role: Dict[str, List[Dict]] = {}
for role in SAMPLE_ROLES:
role_matches = [m for m in matches if m.role == role][:10]
if role_matches:
top_by_role[role] = [
{
"path": m.path,
"name": m.name,
"similarity_score": m.similarity_score,
"bpm": m.fingerprint.bpm,
"key": m.fingerprint.key
}
for m in role_matches
]
# Crear perfil
profile = UserSoundProfile(
preferred_bpm=preferred_bpm,
preferred_key=preferred_key,
preferred_timbre=preferred_timbre,
characteristic_energy_curve=char_energy_curve,
preferred_roles=preferred_roles,
created_from_reference=self.reference_path,
total_matches_analyzed=len(matches),
genre="reggaeton",
top_matches_by_role=top_by_role
)
self._profile = profile
if save:
self._save_profile(profile)
logger.info("Perfil generado - BPM: %.1f, Key: %s, Roles: %s",
preferred_bpm, preferred_key, preferred_roles[:5])
return profile
def _save_profile(self, profile: UserSoundProfile) -> bool:
"""Guarda el perfil en disco."""
try:
profile_data = profile.to_dict()
with open(self.profile_path, 'w', encoding='utf-8') as f:
json.dump(profile_data, f, indent=2, ensure_ascii=False)
logger.info("Perfil guardado en: %s", self.profile_path)
return True
except Exception as e:
logger.error("Error guardando perfil: %s", e)
return False
def load_profile(self) -> Optional[UserSoundProfile]:
"""
Carga el perfil desde disco.
Returns:
UserSoundProfile o None si no existe
"""
if not os.path.exists(self.profile_path):
logger.info("No existe perfil guardado en: %s", self.profile_path)
return None
try:
with open(self.profile_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self._profile = UserSoundProfile.from_dict(data)
logger.info("Perfil cargado desde: %s", self.profile_path)
return self._profile
except Exception as e:
logger.error("Error cargando perfil: %s", e)
return None
def get_user_profile(self) -> UserSoundProfile:
"""
Obtiene el perfil del usuario, cargándolo o generándolo si no existe.
Returns:
UserSoundProfile del usuario
"""
# Intentar cargar
profile = self.load_profile()
if profile:
self._profile = profile
return profile
# Generar nuevo
logger.info("Generando nuevo perfil de usuario...")
return self.generate_user_profile()
def get_recommended_samples(self,
role: str,
count: int = 5,
bpm_tolerance: float = 5.0) -> List[Dict[str, Any]]:
"""
Retorna samples recomendados basados en el perfil del usuario.
Args:
role: Rol del sample deseado (kick, snare, bass, etc.)
count: Número de samples a retornar
bpm_tolerance: Tolerancia de BPM para filtrar
Returns:
Lista de diccionarios con información de samples recomendados
"""
# Asegurar que tenemos perfil
if not self._profile:
self.get_user_profile()
profile = self._profile
if not profile:
logger.warning("No se pudo obtener perfil, usando recomendaciones genéricas")
# Fallback: buscar similares sin perfil
matches = self.find_similar_samples(top_k=count * 3, role_filter=role)
return [
{
"path": m.path,
"name": m.name,
"role": m.role,
"similarity_score": m.similarity_score,
"bpm": m.fingerprint.bpm,
"key": m.fingerprint.key,
"reason": "Similitud directa con referencia"
}
for m in matches[:count]
]
# Buscar en top_matches_by_role del perfil
if role in profile.top_matches_by_role:
matches = profile.top_matches_by_role[role]
# Filtrar por BPM dentro de tolerancia
filtered = [
m for m in matches
if abs(m.get("bpm", 0) - profile.preferred_bpm) <= bpm_tolerance
]
# Si no hay suficientes con BPM cercano, usar todos
if len(filtered) < count:
filtered = matches
recommendations = filtered[:count]
return [
{
"path": r["path"],
"name": r["name"],
"role": role,
"similarity_score": r["similarity_score"],
"bpm": r.get("bpm", 0),
"key": r.get("key", ""),
"reason": f"Match con perfil (Key: {profile.preferred_key}, BPM: {profile.preferred_bpm:.1f})"
}
for r in recommendations
]
# Si no hay matches en el perfil para este rol, buscar en tiempo real
logger.info("No hay matches en perfil para '%s', buscando en librería...", role)
matches = self.find_similar_samples(top_k=count * 2, role_filter=role)
return [
{
"path": m.path,
"name": m.name,
"role": m.role,
"similarity_score": m.similarity_score,
"bpm": m.fingerprint.bpm,
"key": m.fingerprint.key,
"reason": "Búsqueda en tiempo real"
}
for m in matches[:count]
]
def get_profile_summary(self) -> Dict[str, Any]:
"""
Retorna resumen del perfil para debugging/visualización.
Returns:
Diccionario con resumen del perfil
"""
if not self._profile:
self.get_user_profile()
if not self._profile:
return {"error": "No se pudo generar perfil"}
p = self._profile
return {
"preferred_bpm": round(p.preferred_bpm, 1),
"preferred_key": p.preferred_key,
"characteristic_energy_curve": [round(x, 3) for x in p.characteristic_energy_curve[:8]],
"preferred_roles": p.preferred_roles[:5],
"top_matches_by_role_count": {
role: len(matches)
for role, matches in p.top_matches_by_role.items()
},
"total_matches_analyzed": p.total_matches_analyzed,
"created_from": p.created_from_reference,
"genre": p.genre
}
# Funciones de conveniencia globales
_matcher: Optional[ReferenceMatcher] = None
def get_matcher(reference_path: Optional[str] = None,
library_path: Optional[str] = None) -> ReferenceMatcher:
"""Obtiene instancia global del matcher."""
global _matcher
if _matcher is None:
_matcher = ReferenceMatcher(reference_path, library_path)
return _matcher
def get_user_profile(reference_path: Optional[str] = None,
library_path: Optional[str] = None) -> Dict[str, Any]:
"""
Función principal: obtiene o genera el perfil del usuario.
Args:
reference_path: Ruta al archivo de referencia (opcional)
library_path: Ruta a la librería de samples (opcional)
Returns:
Diccionario con el perfil del usuario
"""
matcher = get_matcher(reference_path, library_path)
profile = matcher.get_user_profile()
return profile.to_dict()
def get_recommended_samples(role: str,
count: int = 5,
reference_path: Optional[str] = None,
library_path: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Obtiene samples recomendados para un rol específico.
Args:
role: Rol del sample (kick, snare, bass, synth, etc.)
count: Número de samples a retornar
reference_path: Ruta al archivo de referencia (opcional)
library_path: Ruta a la librería (opcional)
Returns:
Lista de samples recomendados
"""
matcher = get_matcher(reference_path, library_path)
return matcher.get_recommended_samples(role, count)
def analyze_reference(file_path: str) -> Optional[Dict[str, Any]]:
"""
Analiza un archivo de referencia y retorna su fingerprint.
Args:
file_path: Ruta al archivo de audio
Returns:
Diccionario con el fingerprint o None si falla
"""
analyzer = AudioAnalyzer()
fingerprint = analyzer.analyze_file(file_path)
if fingerprint:
return fingerprint.to_dict()
return None
def refresh_profile() -> Dict[str, Any]:
"""
Fuerza la regeneración del perfil del usuario.
Returns:
Nuevo perfil generado
"""
global _matcher
_matcher = None # Reset para forzar regeneración
matcher = get_matcher()
profile = matcher.generate_user_profile(save=True)
return profile.to_dict()
if __name__ == "__main__":
# Test del módulo
logging.basicConfig(level=logging.INFO)
print("=" * 60)
print("Reference Matcher - Test")
print("=" * 60)
# Test 1: Analizar referencia
print("\n1. Analizando referencia...")
matcher = ReferenceMatcher()
ref_fp = matcher.analyze_reference()
if ref_fp:
print(f" BPM: {ref_fp.bpm}")
print(f" Key: {ref_fp.key}")
print(f" Duration: {ref_fp.duration:.2f}s")
# Test 2: Indexar librería
print("\n2. Indexando librería...")
library = matcher.index_library()
print(f" Samples indexados: {len(library)}")
# Test 3: Generar perfil
print("\n3. Generando perfil de usuario...")
profile = matcher.generate_user_profile(top_matches_count=30)
print(f" Preferred BPM: {profile.preferred_bpm:.1f}")
print(f" Preferred Key: {profile.preferred_key}")
print(f" Preferred Roles: {profile.preferred_roles[:3]}")
# Test 4: Recomendaciones
print("\n4. Obteniendo recomendaciones...")
for role in ["kick", "snare", "bass"]:
recs = matcher.get_recommended_samples(role, count=2)
print(f" {role}: {[r['name'] for r in recs]}")
print("\n" + "=" * 60)
print("Test completado!")
print("=" * 60)