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
This commit is contained in:
922
mcp_server/engines/reference_matcher.py
Normal file
922
mcp_server/engines/reference_matcher.py
Normal file
@@ -0,0 +1,922 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user