FASE 3 - Human Feel & Dynamics (10/11 tasks): - apply_clip_fades() - T041: Fade automation per section - write_volume_automation() - T042: Curves (linear, exp, s_curve, punch) - apply_sidechain_pump() - T045: Sidechain by intensity/style - inject_pattern_fills() - T048: Snare rolls, fills by density - humanize_set() - T050: Timing + velocity + groove automation FASE 4 - Key Compatibility & Tonal (9/12 tasks): - audio_key_compatibility.py: Full KEY_COMPATIBILITY_MATRIX - analyze_key_compatibility() - T053: Harmonic compatibility scoring - suggest_key_change() - T054: Circle of fifths modulation - validate_sample_key() - T055: Sample key validation - analyze_spectral_fit() - T057/T062: Spectral role matching FASE 6 - Mastering & QA (8/13 tasks): - calibrate_gain_staging() - T079: Auto gain by bus targets - run_mix_quality_check() - T085: LUFS, peaks, L/R balance - export_stem_mixdown() - T087: 24-bit/44.1kHz stem export New files: - audio_key_compatibility.py (T052) - bus_routing_fix.py (T101-T104) - validation_system_fix.py (T105-T106) Total: 76/110 tasks (69%), 71 MCP tools exposed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2817 lines
108 KiB
Python
2817 lines
108 KiB
Python
"""
|
|
sample_selector.py - Selector inteligente de samples (Fase 4 mejorada)
|
|
|
|
Proporciona:
|
|
- Selección contextual basada en género, key, BPM
|
|
- Matching armónico entre samples
|
|
- Creación de kits de batería coherentes
|
|
- Recomendaciones basadas en compatibilidad
|
|
- Mapeo MIDI automático
|
|
|
|
Mejoras Fase 4:
|
|
- Ranking mejorado con múltiples factores de similitud
|
|
- Diversidad entre corridas con seeding determinista
|
|
- Validación de roles para evitar elecciones absurdas
|
|
- Penalización de familias repetidas
|
|
- Balance one-shots vs loops
|
|
- Soporte opcional para GPU/embeddings
|
|
"""
|
|
|
|
import random
|
|
import logging
|
|
import hashlib
|
|
import time
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
from dataclasses import dataclass, field
|
|
from collections import defaultdict, deque
|
|
from pathlib import Path
|
|
|
|
# Detección de numpy para cálculos vectorizados
|
|
try:
|
|
import numpy as np
|
|
NUMPY_AVAILABLE = True
|
|
except ImportError:
|
|
NUMPY_AVAILABLE = False
|
|
np = None
|
|
|
|
# Detección de GPU (cupy) para aceleración
|
|
try:
|
|
import cupy as cp
|
|
GPU_AVAILABLE = True
|
|
except ImportError:
|
|
GPU_AVAILABLE = False
|
|
cp = None
|
|
|
|
# Imports del sistema de samples
|
|
try:
|
|
from .sample_manager import SampleManager, Sample, get_manager
|
|
from .audio_analyzer import AudioAnalyzer, calculate_key_compatibility
|
|
MANAGER_AVAILABLE = True
|
|
except ImportError:
|
|
try:
|
|
from sample_manager import SampleManager, Sample, get_manager
|
|
from audio_analyzer import AudioAnalyzer, calculate_key_compatibility
|
|
MANAGER_AVAILABLE = True
|
|
except ImportError:
|
|
MANAGER_AVAILABLE = False
|
|
SampleManager = None
|
|
Sample = None
|
|
AudioAnalyzer = None
|
|
calculate_key_compatibility = None
|
|
|
|
logger = logging.getLogger("SampleSelector")
|
|
|
|
# ============================================================================
|
|
# IMPORTS DE MEMORIA DE DIVERSIDAD (Phase 5)
|
|
# ============================================================================
|
|
try:
|
|
from .diversity_memory import (
|
|
get_diversity_memory,
|
|
record_sample_usage,
|
|
record_generation_complete,
|
|
get_penalty_for_sample,
|
|
detect_sample_family,
|
|
DIVERSITY_MEMORY_AVAILABLE
|
|
)
|
|
DIVERSITY_MEMORY_AVAILABLE = True
|
|
except ImportError:
|
|
try:
|
|
from diversity_memory import (
|
|
get_diversity_memory,
|
|
record_sample_usage,
|
|
record_generation_complete,
|
|
get_penalty_for_sample,
|
|
detect_sample_family,
|
|
)
|
|
DIVERSITY_MEMORY_AVAILABLE = True
|
|
except ImportError:
|
|
DIVERSITY_MEMORY_AVAILABLE = False
|
|
get_diversity_memory = None
|
|
record_sample_usage = None
|
|
record_generation_complete = None
|
|
get_penalty_for_sample = None
|
|
detect_sample_family = None
|
|
|
|
# Memoria entre generaciones (legacy, mantener para compatibilidad)
|
|
# Ahora delegamos a diversity_memory.py para persistencia
|
|
_cross_generation_family_memory: Dict[str, int] = defaultdict(int)
|
|
_cross_generation_path_memory: Dict[str, int] = defaultdict(int)
|
|
_cross_generation_generation_count: int = 0
|
|
|
|
_recent_sample_diversity_memory: Dict[str, List[str]] = defaultdict(list)
|
|
RECENT_MEMORY_MAX_PER_ROLE = 50
|
|
|
|
def _get_cross_generation_memory() -> Dict[str, int]:
|
|
"""Retorna copia de la memoria entre generaciones."""
|
|
return _cross_generation_family_memory.copy()
|
|
|
|
def _update_cross_generation_memory(families_used: Dict[str, int], paths_used: List[str] = None) -> None:
|
|
"""Actualiza memoria cross-generation con familias y paths usados.
|
|
|
|
Esta función ahora delega principalmente a diversity_memory.py para
|
|
persistencia persistente, pero mantiene la memoria en memoria para
|
|
compatibilidad con código existente.
|
|
"""
|
|
global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count
|
|
_cross_generation_generation_count += 1
|
|
|
|
# Delegar al sistema de memoria persistente
|
|
if DIVERSITY_MEMORY_AVAILABLE:
|
|
try:
|
|
record_generation_complete()
|
|
logger.debug("Memoria cross-generation persistida (generación %d)", _cross_generation_generation_count)
|
|
except Exception as e:
|
|
logger.warning("Error actualizando memoria persistente: %s", e)
|
|
|
|
# Mantener memoria en RAM para compatibilidad
|
|
for family in list(_cross_generation_family_memory.keys()):
|
|
_cross_generation_family_memory[family] = max(0, _cross_generation_family_memory[family] - 1)
|
|
|
|
for path in list(_cross_generation_path_memory.keys()):
|
|
_cross_generation_path_memory[path] = max(0, _cross_generation_path_memory[path] - 1)
|
|
|
|
for family, count in families_used.items():
|
|
_cross_generation_family_memory[family] += count
|
|
|
|
if paths_used:
|
|
for path in paths_used:
|
|
_cross_generation_path_memory[path] += 1
|
|
|
|
_cross_generation_family_memory = {k: v for k, v in _cross_generation_family_memory.items() if v > 0}
|
|
_cross_generation_path_memory = {k: v for k, v in _cross_generation_path_memory.items() if v > 0}
|
|
|
|
def reset_cross_generation_memory() -> None:
|
|
"""Limpia toda la memoria cross-generation (RAM y persistente)."""
|
|
global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count, _recent_sample_diversity_memory
|
|
|
|
# Limpiar memoria persistente
|
|
if DIVERSITY_MEMORY_AVAILABLE:
|
|
try:
|
|
from .diversity_memory import reset_diversity_memory
|
|
reset_diversity_memory()
|
|
logger.info("Memoria de diversidad persistente reseteada")
|
|
except ImportError:
|
|
try:
|
|
from diversity_memory import reset_diversity_memory
|
|
reset_diversity_memory()
|
|
logger.info("Memoria de diversidad persistente reseteada")
|
|
except ImportError:
|
|
pass
|
|
|
|
# Limpiar memoria en RAM
|
|
_cross_generation_family_memory.clear()
|
|
_cross_generation_path_memory.clear()
|
|
_cross_generation_generation_count = 0
|
|
_recent_sample_diversity_memory.clear()
|
|
|
|
def add_to_recent_memory(role: str, sample_path: str) -> None:
|
|
"""Add a sample path to the recent memory for its role."""
|
|
global _recent_sample_diversity_memory
|
|
if role not in _recent_sample_diversity_memory:
|
|
_recent_sample_diversity_memory[role] = []
|
|
if sample_path not in _recent_sample_diversity_memory[role]:
|
|
_recent_sample_diversity_memory[role].append(sample_path)
|
|
if len(_recent_sample_diversity_memory[role]) > RECENT_MEMORY_MAX_PER_ROLE:
|
|
_recent_sample_diversity_memory[role] = _recent_sample_diversity_memory[role][-RECENT_MEMORY_MAX_PER_ROLE:]
|
|
|
|
def get_recent_memory_penalty(role: str, sample_path: str) -> float:
|
|
"""Get penalty for a sample that was recently used for the same role.Returns 1.0 (no penalty) to 0.1 (strong penalty)."""
|
|
global _recent_sample_diversity_memory
|
|
role_samples = _recent_sample_diversity_memory.get(role, [])
|
|
if sample_path not in role_samples:
|
|
return 1.0
|
|
position = role_samples.index(sample_path)
|
|
recency = len(role_samples) - position
|
|
if recency <= 5:
|
|
return 0.1
|
|
elif recency <= 10:
|
|
return 0.25
|
|
elif recency <= 20:
|
|
return 0.5
|
|
elif recency <= 30:
|
|
return 0.7
|
|
else:
|
|
return 0.85
|
|
|
|
def get_recent_sample_diversity_state() -> Dict[str, List[str]]:
|
|
"""Get copy of recent sample diversity memory."""
|
|
return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()}
|
|
|
|
def sync_cross_generation_memory_from_reference(families: Dict[str, int], paths: Dict[str, int]) -> None:
|
|
"""Sincroniza memoria cross-generation con reference_listener (para consistencia)."""
|
|
global _cross_generation_family_memory, _cross_generation_path_memory
|
|
for family, count in families.items():
|
|
if count > 0:
|
|
_cross_generation_family_memory[family] = max(
|
|
_cross_generation_family_memory.get(family, 0), count
|
|
)
|
|
for path, count in paths.items():
|
|
if count > 0:
|
|
_cross_generation_path_memory[path] = max(
|
|
_cross_generation_path_memory.get(path, 0), count
|
|
)
|
|
|
|
def get_cross_generation_state() -> Tuple[Dict[str, int], Dict[str, int]]:
|
|
"""Retorna la memoria cross-generation actual (familias, paths)."""
|
|
return (
|
|
dict(_cross_generation_family_memory),
|
|
dict(_cross_generation_path_memory)
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SampleDecision:
|
|
"""Registro estructurado de decisión de selección de sample."""
|
|
sample_name: str
|
|
target_role: str
|
|
final_score: float
|
|
selected: bool
|
|
rejection_reasons: list[str] = field(default_factory=list)
|
|
bonus_factors: list[str] = field(default_factory=list)
|
|
selection_index: int = -1 # Position in ranking
|
|
|
|
def to_log_str(self) -> str:
|
|
"""Genera string loggable."""
|
|
if self.selected:
|
|
bonuses = ", ".join(self.bonus_factors) if self.bonus_factors else "none"
|
|
return f"SELECTED: {self.sample_name} for {self.target_role} (score={self.final_score:.3f}, bonuses={bonuses})"
|
|
else:
|
|
reasons = ", ".join(self.rejection_reasons) if self.rejection_reasons else "low score"
|
|
return f"REJECTED: {self.sample_name} for {self.target_role} ({reasons})"
|
|
|
|
|
|
class GenreProfile:
|
|
"""Perfil musical para un género específico"""
|
|
|
|
def __init__(self,
|
|
name: str,
|
|
bpm_range: Tuple[int, int],
|
|
common_keys: List[str],
|
|
drum_pattern: str,
|
|
bass_style: str,
|
|
characteristics: List[str]):
|
|
self.name = name
|
|
self.bpm_range = bpm_range
|
|
self.common_keys = common_keys
|
|
self.drum_pattern = drum_pattern
|
|
self.bass_style = bass_style
|
|
self.characteristics = characteristics
|
|
|
|
|
|
# Perfiles de géneros musicales
|
|
GENRE_PROFILES = {
|
|
'techno': GenreProfile(
|
|
name='Techno',
|
|
bpm_range=(125, 140),
|
|
common_keys=['F#m', 'Am', 'Dm', 'Gm', 'Cm'],
|
|
drum_pattern='four_on_floor',
|
|
bass_style='rolling',
|
|
characteristics=['driving', 'industrial', 'repetitive', 'dark']
|
|
),
|
|
'industrial-techno': GenreProfile(
|
|
name='Industrial Techno',
|
|
bpm_range=(135, 150),
|
|
common_keys=['F#m', 'Am', 'Dm'],
|
|
drum_pattern='distorted_four',
|
|
bass_style='aggressive',
|
|
characteristics=['distorted', 'harsh', 'mechanical', 'dark']
|
|
),
|
|
'minimal-techno': GenreProfile(
|
|
name='Minimal Techno',
|
|
bpm_range=(124, 130),
|
|
common_keys=['F#m', 'Am', 'Em'],
|
|
drum_pattern='sparse',
|
|
bass_style='minimal',
|
|
characteristics=['stripped', 'subtle', 'groove', 'reduced']
|
|
),
|
|
'house': GenreProfile(
|
|
name='House',
|
|
bpm_range=(118, 128),
|
|
common_keys=['Am', 'Fm', 'Cm', 'Gm', 'Dm'],
|
|
drum_pattern='classic_house',
|
|
bass_style='funky',
|
|
characteristics=['soulful', 'groovy', 'warm', 'organic']
|
|
),
|
|
'deep-house': GenreProfile(
|
|
name='Deep House',
|
|
bpm_range=(120, 124),
|
|
common_keys=['Am', 'Fm', 'Dm', 'Gm'],
|
|
drum_pattern='deep_house',
|
|
bass_style='subby',
|
|
characteristics=['deep', 'jazzy', 'warm', 'mellow']
|
|
),
|
|
'tech-house': GenreProfile(
|
|
name='Tech House',
|
|
bpm_range=(124, 128),
|
|
common_keys=['F#m', 'Am', 'Gm', 'Cm'],
|
|
drum_pattern='bouncy',
|
|
bass_style='groovy',
|
|
characteristics=['bouncy', 'funky', 'percussive', 'club']
|
|
),
|
|
'progressive-house': GenreProfile(
|
|
name='Progressive House',
|
|
bpm_range=(126, 132),
|
|
common_keys=['Fm', 'Am', 'Dm', 'Gm'],
|
|
drum_pattern='progressive',
|
|
bass_style='driving',
|
|
characteristics=['epic', 'buildup', 'melodic', 'anthem']
|
|
),
|
|
'trance': GenreProfile(
|
|
name='Trance',
|
|
bpm_range=(135, 150),
|
|
common_keys=['Fm', 'Am', 'Dm', 'Gm'],
|
|
drum_pattern='trance',
|
|
bass_style='rolling',
|
|
characteristics=['euphoric', 'melodic', 'uplifting', 'energetic']
|
|
),
|
|
'psytrance': GenreProfile(
|
|
name='Psytrance',
|
|
bpm_range=(140, 150),
|
|
common_keys=['Fm', 'Gm', 'Am'],
|
|
drum_pattern='psy',
|
|
bass_style='acid',
|
|
characteristics=['psychedelic', 'acid', 'complex', 'trippy']
|
|
),
|
|
'drum-and-bass': GenreProfile(
|
|
name='Drum & Bass',
|
|
bpm_range=(160, 180),
|
|
common_keys=['Am', 'Fm', 'Dm', 'Gm'],
|
|
drum_pattern='breakbeat',
|
|
bass_style='reese',
|
|
characteristics=['fast', 'heavy', 'complex', 'energetic']
|
|
),
|
|
'liquid-dnb': GenreProfile(
|
|
name='Liquid Drum & Bass',
|
|
bpm_range=(168, 174),
|
|
common_keys=['Am', 'Fm', 'Dm'],
|
|
drum_pattern='liquid',
|
|
bass_style='musical',
|
|
characteristics=['smooth', 'soulful', 'melodic', 'rolling']
|
|
),
|
|
'ambient': GenreProfile(
|
|
name='Ambient',
|
|
bpm_range=(80, 110),
|
|
common_keys=['C', 'Dm', 'Am', 'Em'],
|
|
drum_pattern='none',
|
|
bass_style='droning',
|
|
characteristics=['atmospheric', 'textural', 'slow', 'ethereal']
|
|
),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# MAPEO DE ROLES VALIDOS - Evita elecciones absurdas
|
|
# ============================================================================
|
|
# Define qué tipos de samples son válidos para cada rol de drum
|
|
DRUM_ROLE_VALID_TYPES = {
|
|
'kick': ['kick', 'bd', 'bass_drum', 'kickdrum', '808'],
|
|
'snare': ['snare', 'snr', 'sd', 'rimshot', 'rim'],
|
|
'clap': ['clap', 'clp', 'handclap'],
|
|
'hat_closed': ['hat_closed', 'closed_hat', 'chh', 'hihat', 'hat'],
|
|
'hat_open': ['hat_open', 'open_hat', 'ohh', 'hihat'],
|
|
'hat_pedal': ['hat_pedal', 'pedal_hat', 'hihat'],
|
|
'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale', 'tamb', 'shaker'],
|
|
'tom': ['tom', 'tomtom'],
|
|
'crash': ['crash', 'cymbal', 'china'],
|
|
'ride': ['ride', 'cymbal', 'ride_bell'],
|
|
}
|
|
|
|
# Mapeo inverso: dado un sample_type, qué roles puede ocupar
|
|
SAMPLE_TYPE_TO_ROLES = defaultdict(list)
|
|
for role, valid_types in DRUM_ROLE_VALID_TYPES.items():
|
|
for stype in valid_types:
|
|
SAMPLE_TYPE_TO_ROLES[stype].append(role)
|
|
|
|
# Cooldown: families no se reusarán hasta después de N selecciones
|
|
COOLDOWN_WINDOW = 10 # Numero de selecciones antes de que una familia pueda reutilizarse
|
|
|
|
# Familias de samples para penalización de repeticiones
|
|
SAMPLE_FAMILIES = {
|
|
# Drums - por fabricante/estilo
|
|
'808': ['808', 'tr808', 'tr-808'],
|
|
'909': ['909', 'tr909', 'tr-909'],
|
|
'acoustic': ['acoustic', 'real', 'live', 'studio'],
|
|
'electronic': ['electronic', 'digital', 'synthetic', 'synth'],
|
|
'vintage': ['vintage', 'classic', 'old', 'retro'],
|
|
'modern': ['modern', 'contemporary', 'new'],
|
|
# Bass - por tipo
|
|
'sub': ['sub', 'subby', 'subby'],
|
|
'reese': ['reese', 'reese_bass'],
|
|
'acid': ['acid', '303', 'tb303'],
|
|
# Synth - por tipo
|
|
'analog': ['analog', 'analogue', 'moog', 'oberheim'],
|
|
'digital': ['digital', 'fm', 'wavetable', 'serum'],
|
|
'vocal': ['vocal', 'voice', 'vox'],
|
|
}
|
|
|
|
# Umbrales para clasificación one-shot vs loop
|
|
ONESHOT_MAX_DURATION = 2.0 # segundos
|
|
LOOP_MIN_DURATION = 1.0 # segundos
|
|
|
|
# Preferencia one-shot vs loop por rol
|
|
# True = prefiere one-shot, False = prefiere loop, None = sin preferencia
|
|
ROLE_ONE_SHOT_PREFERENCE = {
|
|
'kick': True, # Debe ser one-shot
|
|
'clap': True, # Debe ser one-shot
|
|
'hat': True, # Debe ser one-shot
|
|
'hat_closed': True,
|
|
'hat_open': True,
|
|
'snare': True,
|
|
'bass_loop': False, # Debe ser loop
|
|
'vocal_loop': False, # Debe ser loop
|
|
'perc_loop': False,
|
|
'top_loop': False,
|
|
'synth_loop': False,
|
|
}
|
|
|
|
# Patrones de rechazo duro para roles críticos
|
|
# Estos son ERRORES semanticos que nunca deberían pasar
|
|
# Expandidos para endurecimiento del sistema (Problema #4)
|
|
HARD_REJECT_PATTERNS = {
|
|
'kick': {
|
|
'exclude_keywords': [
|
|
'roll', 'fill', 'loop', 'hat', 'snare', 'clap', 'vocal', 'synth', 'pad',
|
|
'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song',
|
|
'master', 'top loop', 'top_loop', 'drum loop', 'drum_loop', 'perc loop',
|
|
'melodic', 'chord', 'stab', 'fx', 'riser', 'downlifter', 'atmos',
|
|
'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix'
|
|
],
|
|
'exclude_subcategories': ['snare', 'hat', 'clap', 'perc', 'fx', 'vocal', 'synth'],
|
|
'max_duration': 2.0, # Stricter: kicks longer than 2s are loops
|
|
'must_contain_none': ['full', 'mix', 'demo', 'song', 'master'],
|
|
'must_contain_one': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'],
|
|
},
|
|
'clap': {
|
|
'exclude_keywords': [
|
|
'roll', 'fill', 'loop', 'hat', 'kick', 'vocal', 'bass',
|
|
'full drum', 'full mix', 'demo', 'song', 'master', 'top', 'perc loop',
|
|
'snare roll', 'snare_roll', 'snareroll', 'complete', 'mixed', 'stems'
|
|
],
|
|
'exclude_subcategories': ['kick', 'hat', 'fx', 'vocal', 'bass'],
|
|
'must_contain_one': ['clap', 'hand', 'handclap'],
|
|
'max_duration': 2.0,
|
|
'must_contain_none': ['full', 'mix', 'snare roll', 'snare_roll'],
|
|
},
|
|
'hat': {
|
|
'exclude_keywords': [
|
|
'roll', 'kick', 'snare', 'clap', 'vocal', 'bass', 'synth', 'pad',
|
|
'full drum', 'full mix', 'demo', 'song', 'master', 'bass loop',
|
|
'top loop', 'drum loop', 'perc loop', 'full_mix', 'fulldrum',
|
|
'complete', 'mixed', 'stems', 'kick drum', 'snare drum'
|
|
],
|
|
'exclude_subcategories': ['kick', 'snare', 'clap', 'bass', 'vocal'],
|
|
'max_duration': 1.5,
|
|
'must_contain_none': ['full', 'mix', 'demo', 'complete'],
|
|
'must_contain_one': ['hat', 'hh', 'hihat', 'hi-hat', 'cymbal', 'open hat', 'closed hat'],
|
|
},
|
|
'bass_loop': {
|
|
'exclude_keywords': [
|
|
'drum', 'hat', 'kick', 'snare', 'clap', 'perc', 'top loop', 'top_loop',
|
|
'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song',
|
|
'master', 'vocal', 'vocal loop', 'vocal_loop', 'fx', 'atmos', 'pad',
|
|
'drum loop', 'drum_loop', 'perc loop', 'melodic', 'chord', 'synth loop',
|
|
'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix'
|
|
],
|
|
'exclude_subcategories': ['drum', 'perc', 'fx', 'vocal', 'hat'],
|
|
'min_duration': 2.0,
|
|
'must_contain_one': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
|
|
'must_contain_none': ['full', 'mix', 'drum', 'top', 'vocal'],
|
|
},
|
|
'vocal_loop': {
|
|
'exclude_keywords': [
|
|
'drum', 'hat', 'kick', 'snare', 'bass', 'synth', 'pad', 'fx',
|
|
'full drum', 'full mix', 'demo', 'song', 'master', 'one shot', 'oneshot',
|
|
'shot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop',
|
|
'complete', 'mixed', 'stems', 'bounce', 'preview', 'loop kit'
|
|
],
|
|
'exclude_subcategories': ['drum', 'bass', 'perc', 'fx', 'hat'],
|
|
'min_duration': 2.0, # Must be at least 2s to be a loop
|
|
'must_contain_one': ['vocal', 'vox', 'voice', 'sing', 'chorus', 'verse', 'chant', 'acapella'],
|
|
'must_contain_none': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot'],
|
|
},
|
|
'top_loop': {
|
|
'exclude_keywords': [
|
|
'bass', 'bass loop', 'vocal', 'vocal loop', 'synth loop', 'pad',
|
|
'demo', 'song', 'master', 'fx', 'atmos', 'riser', 'downlifter',
|
|
'melodic', 'chord', 'stab', 'complete', 'mixed', 'stems', 'snare roll'
|
|
],
|
|
'exclude_subcategories': ['bass', 'vocal', 'fx', 'pad', 'synth'],
|
|
'must_contain_one': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'conga', 'bongo', 'full drum'],
|
|
'min_duration': 1.5,
|
|
'must_contain_none': ['bass', 'vocal', 'synth loop'],
|
|
},
|
|
'fill_fx': {
|
|
'exclude_keywords': [
|
|
'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad',
|
|
'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop',
|
|
'complete', 'mixed', 'stems', 'bass loop', 'vocal loop'
|
|
],
|
|
'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass', 'vocal'],
|
|
'must_contain_one': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition', 'downlifter'],
|
|
'max_duration': 4.0,
|
|
},
|
|
'snare_roll': {
|
|
'exclude_keywords': [
|
|
'kick', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad',
|
|
'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'atmos',
|
|
'complete', 'mixed', 'stems', 'one shot', 'drum loop', 'bass loop'
|
|
],
|
|
'exclude_subcategories': ['kick', 'hat', 'clap', 'bass', 'vocal', 'fx'],
|
|
'must_contain_one': ['snare', 'roll', 'fill', 'snareroll', 'buildup', 'build up'],
|
|
'max_duration': 4.0,
|
|
},
|
|
'atmos_fx': {
|
|
'exclude_keywords': [
|
|
'kick', 'snare', 'hat', 'clap', 'bass', 'vocal loop',
|
|
'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop',
|
|
'complete', 'mixed', 'stems', 'snare roll', 'fill', 'perc loop'
|
|
],
|
|
'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass'],
|
|
'must_contain_one': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'fx', 'riser', 'noise', 'ambient'],
|
|
'min_duration': 2.0,
|
|
},
|
|
'crash_fx': {
|
|
'must_contain_one': ['crash', 'impact', 'cymbal', 'ride', 'uplifter', 'downlifter'],
|
|
'exclude_keywords': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'drum loop', 'complete kit'],
|
|
'max_duration': 3.0,
|
|
},
|
|
'synth_loop': {
|
|
'exclude_keywords': [
|
|
'drum', 'kick', 'snare', 'hat', 'vocal', 'bass loop', 'full mix',
|
|
'demo', 'song', 'master', 'complete', 'mixed', 'stems', 'drum loop',
|
|
'perc loop', 'top loop', 'one shot'
|
|
],
|
|
'must_contain_one': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody', 'hook', 'sequence'],
|
|
'min_duration': 1.5,
|
|
},
|
|
}
|
|
|
|
# Keywords sospechosos que penalizan (pero no rechazan) el score
|
|
# Penalización soft del 30% por cada keyword encontrado
|
|
SUSPICIOUS_KEYWORDS = {
|
|
'kick': ['full', 'mix', 'demo', 'song', 'master', 'complete', 'stereo', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'kit', 'pack'],
|
|
'clap': ['full', 'mix', 'demo', 'song', 'snare roll', 'snare_roll', 'fill', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'loop', 'groove', 'top loop'],
|
|
'hat': ['full', 'mix', 'demo', 'song', 'loop', 'complete', 'stems', 'full kit',
|
|
'bounce', 'preview', 'final', 'mixed', 'kick', 'snare', 'bass'],
|
|
'bass_loop': ['full', 'mix', 'demo', 'vocal', 'top', 'drum loop', 'full drum', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'perc', 'snare', 'hat', 'kick'],
|
|
'vocal_loop': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot', 'drum', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'bass loop', 'loop kit'],
|
|
'top_loop': ['bass', 'vocal', 'synth loop', 'demo', 'stems', 'snare roll',
|
|
'bounce', 'preview', 'final', 'mixed', 'percussion', 'hat loop'],
|
|
'fill_fx': ['loop', 'groove', 'kick', 'snare', 'bass', 'vocal', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'drum loop'],
|
|
'snare_roll': ['loop', 'groove', 'kick', 'hat', 'bass', 'vocal', 'atmos', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'clap'],
|
|
'atmos_fx': ['kick', 'snare', 'hat', 'clap', 'bass', 'loop', 'groove', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'drum loop', 'vocal loop'],
|
|
'synth_loop': ['drum', 'vocal', 'bass loop', 'full mix', 'demo', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'one shot', 'hit'],
|
|
'crash_fx': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'hat loop', 'top loop'],
|
|
'perc_loop': ['bass', 'vocal', 'synth', 'demo', 'full mix', 'stems',
|
|
'bounce', 'preview', 'final', 'mixed', 'snare roll'],
|
|
}
|
|
|
|
# Keywords requeridos por rol - Validación positiva
|
|
ROLE_REQUIRED_KEYWORDS = {
|
|
'kick': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'],
|
|
'snare': ['snare', 'snr', 'sd', 'rim', 'rimshot'],
|
|
'clap': ['clap', 'clp', 'handclap', 'hand clap'],
|
|
'hat': ['hat', 'hh', 'hihat', 'hi hat', 'hi-hat', 'closed hat', 'open hat', 'cymbal'],
|
|
'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
|
|
'vocal_loop': ['vocal', 'vox', 'voice', 'acapella', 'chant', 'sing'],
|
|
'top_loop': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'full drum'],
|
|
'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody'],
|
|
'crash_fx': ['crash', 'cymbal', 'impact', 'ride', 'uplifter'],
|
|
'fill_fx': ['fill', 'transition', 'tom', 'break', 'riser'],
|
|
'snare_roll': ['roll', 'snare', 'build', 'buildup', 'snareroll'],
|
|
'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise', 'sweep'],
|
|
'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab', 'importante'],
|
|
'perc_loop': ['perc', 'percussion', 'shaker', 'conga', 'bongo'],
|
|
}
|
|
|
|
# ============================================================================
|
|
# SISTEMA DE EXCLUSIONES POR ROL - Problema #4
|
|
# Define qué samples NO son apropiados para cada rol
|
|
# ============================================================================
|
|
ROLE_EXCLUSION_PATTERNS = {
|
|
'kick': {
|
|
'exclude_keywords': [
|
|
'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix', 'demo', 'song',
|
|
'master', 'top loop', 'drum loop', 'snare roll', 'fill', 'hat loop',
|
|
'vocal loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview',
|
|
'snare', 'clap', 'hat', 'bass loop', 'vocal', 'synth', 'pad', 'atmos'
|
|
],
|
|
'max_duration': 2.5, # Reject if longer than 2.5s
|
|
'min_required_keywords': ['kick', 'bd', 'bass_drum', '808', 'kickdrum'],
|
|
},
|
|
'clap': {
|
|
'exclude_keywords': [
|
|
'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
|
|
'snare roll', 'snare_roll', 'hat loop', 'kick loop', 'top loop', 'drum loop',
|
|
'bass loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview',
|
|
'kick', 'hat', 'vocal', 'bass', 'synth', 'pad'
|
|
],
|
|
'max_duration': 2.0,
|
|
'min_required_keywords': ['clap', 'hand', 'handclap'],
|
|
},
|
|
'hat': {
|
|
'exclude_keywords': [
|
|
'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
|
|
'kick loop', 'snare loop', 'bass loop', 'vocal loop', 'complete', 'full kit',
|
|
'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'clap', 'bass'
|
|
],
|
|
'max_duration': 2.0,
|
|
'min_required_keywords': ['hat', 'hh', 'hihat', 'cymbal', 'open hat', 'closed hat'],
|
|
},
|
|
'bass_loop': {
|
|
'exclude_keywords': [
|
|
'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
|
|
'top loop', 'vocal loop', 'vocal_loop', 'drum loop', 'hat loop', 'snare loop',
|
|
'perc loop', 'fx loop', 'atmos', 'complete', 'mixed', 'stems', 'bounce', 'preview',
|
|
'kick', 'snare', 'hat', 'vocal'
|
|
],
|
|
'min_duration': 1.5,
|
|
'min_required_keywords': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
|
|
},
|
|
'vocal_loop': {
|
|
'exclude_keywords': [
|
|
'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
|
|
'one shot', 'oneshot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop',
|
|
'complete', 'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'hat', 'bass'
|
|
],
|
|
'min_duration': 1.5,
|
|
'min_required_keywords': ['vocal', 'vox', 'voice', 'sing', 'chant', 'acapella', 'phrase'],
|
|
},
|
|
'top_loop': {
|
|
'exclude_keywords': [
|
|
'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop', 'demo', 'song', 'master',
|
|
'synth loop', 'pad', 'atmos', 'riser', 'downlifter', 'complete', 'mixed',
|
|
'stems', 'bounce', 'preview', 'bass', 'vocal', 'synth'
|
|
],
|
|
'min_duration': 1.0,
|
|
'min_required_keywords': ['top', 'perc', 'drum', 'groove', 'hat', 'full drum', 'drum loop'],
|
|
},
|
|
'fill_fx': {
|
|
'exclude_keywords': [
|
|
'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song',
|
|
'master', 'loop', 'groove', 'complete', 'mixed', 'stems', 'bounce', 'preview',
|
|
'drum loop', 'bass loop', 'vocal loop'
|
|
],
|
|
'max_duration': 6.0,
|
|
'min_required_keywords': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition'],
|
|
},
|
|
'snare_roll': {
|
|
'exclude_keywords': [
|
|
'kick', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song', 'master',
|
|
'atmos', 'pad', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'one shot',
|
|
'loop', 'groove'
|
|
],
|
|
'max_duration': 6.0,
|
|
'min_required_keywords': ['roll', 'snare', 'fill', 'buildup', 'build up', 'snareroll'],
|
|
},
|
|
'atmos_fx': {
|
|
'exclude_keywords': [
|
|
'kick', 'snare', 'hat', 'clap', 'bass', 'full mix', 'demo', 'song', 'master',
|
|
'drum loop', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'snare roll',
|
|
'fill', 'perc loop', 'vocal'
|
|
],
|
|
'min_duration': 1.5,
|
|
'min_required_keywords': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'noise', 'ambient'],
|
|
},
|
|
'crash_fx': {
|
|
'exclude_keywords': [
|
|
'full mix', 'demo', 'song', 'master', 'loop', 'complete', 'mixed', 'stems',
|
|
'bounce', 'preview', 'bass', 'vocal', 'kick', 'snare'
|
|
],
|
|
'max_duration': 4.0,
|
|
'min_required_keywords': ['crash', 'cymbal', 'impact', 'ride', 'uplifter', 'downlifter'],
|
|
},
|
|
}
|
|
|
|
|
|
def _check_role_exclusion(sample_name: str, role: str) -> Tuple[bool, str]:
|
|
"""
|
|
Verifica si un sample debe ser excluido para un rol específico.
|
|
|
|
Returns:
|
|
(excluded, reason) - True si debe ser excluido, False si pasa
|
|
"""
|
|
role_lower = role.lower()
|
|
if role_lower not in ROLE_EXCLUSION_PATTERNS:
|
|
return False, ""
|
|
|
|
patterns = ROLE_EXCLUSION_PATTERNS[role_lower]
|
|
name_lower = sample_name.lower()
|
|
|
|
# Check excluded keywords
|
|
for keyword in patterns.get('exclude_keywords', []):
|
|
if keyword in name_lower:
|
|
return True, f"excluded keyword '{keyword}'"
|
|
|
|
# Check required keywords
|
|
required = patterns.get('min_required_keywords', [])
|
|
if required:
|
|
found = any(kw in name_lower for kw in required)
|
|
if not found:
|
|
return True, f"missing required keyword (need one of: {required})"
|
|
|
|
return False, ""
|
|
|
|
ROLE_DURATION_RANGES = {
|
|
'kick': (0.05, 2.5),
|
|
'snare': (0.05, 3.0),
|
|
'clap': (0.05, 2.0),
|
|
'hat': (0.05, 2.0),
|
|
'bass_loop': (1.5, 32.0),
|
|
'vocal_loop': (1.0, 32.0),
|
|
'top_loop': (0.75, 32.0),
|
|
'synth_loop': (0.75, 32.0),
|
|
'crash_fx': (0.3, 8.0),
|
|
'fill_fx': (0.3, 12.0),
|
|
'snare_roll': (0.5, 12.0),
|
|
'atmos_fx': (0.5, 32.0),
|
|
'vocal_shot': (0.1, 4.0),
|
|
'perc_loop': (0.75, 32.0),
|
|
}
|
|
|
|
|
|
def _extract_sample_family(sample_name: str) -> str:
|
|
"""Extrae la familia de un sample basado en su nombre."""
|
|
name_lower = sample_name.lower()
|
|
for family, keywords in SAMPLE_FAMILIES.items():
|
|
for kw in keywords:
|
|
if kw in name_lower:
|
|
return family
|
|
return 'unknown'
|
|
|
|
|
|
def _is_oneshot(sample: 'Sample') -> bool:
|
|
"""Determina si un sample es one-shot basado en duración y nombre."""
|
|
name_lower = sample.name.lower()
|
|
duration = sample.duration or 0
|
|
|
|
# Indicadores de one-shot en el nombre
|
|
oneshot_keywords = ['one shot', 'oneshot', 'hit', 'single', 'stab']
|
|
if any(kw in name_lower for kw in oneshot_keywords):
|
|
return True
|
|
|
|
# Indicadores de loop en el nombre
|
|
loop_keywords = ['loop', 'groove', 'pattern', 'sequence']
|
|
if any(kw in name_lower for kw in loop_keywords):
|
|
return False
|
|
|
|
# Por duración
|
|
if duration > 0:
|
|
return duration < ONESHOT_MAX_DURATION
|
|
|
|
# Default: asumir one-shot para drums
|
|
return sample.category == 'drums'
|
|
|
|
|
|
# ============================================================================
|
|
# MAPEO MIDI
|
|
# ============================================================================
|
|
|
|
# Mapeo de notas MIDI para diferentes tipos de samples
|
|
MIDI_NOTE_MAPPING = {
|
|
# Drums (General MIDI)
|
|
'kick': 36, # C1
|
|
'kick_deep': 35, # B0
|
|
'snare': 38, # D1
|
|
'snare_rim': 37, # C#1
|
|
'clap': 39, # D#1 / también 50 (D2)
|
|
'hat_closed': 42, # F#1
|
|
'hat_open': 46, # A#1
|
|
'hat_pedal': 44, # G#1
|
|
'tom_low': 41, # F1
|
|
'tom_mid': 47, # B1
|
|
'tom_high': 50, # D2
|
|
'crash': 49, # C#2
|
|
'ride': 51, # D#2
|
|
'ride_bell': 53, # F2
|
|
'perc_low': 43, # G1
|
|
'perc_mid': 45, # A1
|
|
'perc_high': 48, # C2
|
|
'shaker': 54, # F#2
|
|
'tambourine': 54, # F#2
|
|
'cowbell': 56, # G#2
|
|
|
|
# Instrumentos melódicos (rango usable)
|
|
'bass': list(range(36, 48)), # C1-B1
|
|
'lead': list(range(60, 84)), # C4-B6
|
|
'pad': list(range(48, 72)), # C2-B4
|
|
'pluck': list(range(60, 84)), # C4-B6
|
|
'arp': list(range(60, 84)), # C4-B6
|
|
'chord': list(range(48, 72)), # C2-B4
|
|
'vocal': list(range(60, 84)), # C4-B6
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class DrumKit:
|
|
"""Kit de batería completo"""
|
|
name: str
|
|
kick: Optional[Sample] = None
|
|
snare: Optional[Sample] = None
|
|
clap: Optional[Sample] = None
|
|
hat_closed: Optional[Sample] = None
|
|
hat_open: Optional[Sample] = None
|
|
perc1: Optional[Sample] = None
|
|
perc2: Optional[Sample] = None
|
|
tom: Optional[Sample] = None
|
|
crash: Optional[Sample] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convierte el kit a diccionario"""
|
|
return {
|
|
'name': self.name,
|
|
'kick': self.kick.to_dict() if self.kick else None,
|
|
'snare': self.snare.to_dict() if self.snare else None,
|
|
'clap': self.clap.to_dict() if self.clap else None,
|
|
'hat_closed': self.hat_closed.to_dict() if self.hat_closed else None,
|
|
'hat_open': self.hat_open.to_dict() if self.hat_open else None,
|
|
'perc1': self.perc1.to_dict() if self.perc1 else None,
|
|
'perc2': self.perc2.to_dict() if self.perc2 else None,
|
|
'tom': self.tom.to_dict() if self.tom else None,
|
|
'crash': self.crash.to_dict() if self.crash else None,
|
|
}
|
|
|
|
def get_midi_mapping(self) -> Dict[int, Optional[Sample]]:
|
|
"""Retorna mapeo de notas MIDI a samples"""
|
|
mapping = {}
|
|
if self.kick:
|
|
mapping[MIDI_NOTE_MAPPING['kick']] = self.kick
|
|
if self.snare:
|
|
mapping[MIDI_NOTE_MAPPING['snare']] = self.snare
|
|
if self.clap:
|
|
mapping[MIDI_NOTE_MAPPING['clap']] = self.clap
|
|
if self.hat_closed:
|
|
mapping[MIDI_NOTE_MAPPING['hat_closed']] = self.hat_closed
|
|
if self.hat_open:
|
|
mapping[MIDI_NOTE_MAPPING['hat_open']] = self.hat_open
|
|
if self.tom:
|
|
mapping[MIDI_NOTE_MAPPING['tom_mid']] = self.tom
|
|
if self.crash:
|
|
mapping[MIDI_NOTE_MAPPING['crash']] = self.crash
|
|
return mapping
|
|
|
|
|
|
@dataclass
|
|
class InstrumentGroup:
|
|
"""Grupo de instrumentos para un estilo"""
|
|
genre: str
|
|
key: str
|
|
bpm: float
|
|
drums: DrumKit = field(default_factory=lambda: DrumKit(name="default"))
|
|
bass: List[Sample] = field(default_factory=list)
|
|
synths: List[Sample] = field(default_factory=list)
|
|
fx: List[Sample] = field(default_factory=list)
|
|
vocals: List[Sample] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
'genre': self.genre,
|
|
'key': self.key,
|
|
'bpm': self.bpm,
|
|
'drums': self.drums.to_dict(),
|
|
'bass': [s.to_dict() for s in self.bass],
|
|
'synths': [s.to_dict() for s in self.synths],
|
|
'fx': [s.to_dict() for s in self.fx],
|
|
'vocals': [s.to_dict() for s in self.vocals],
|
|
}
|
|
|
|
|
|
class SampleSelector:
|
|
"""
|
|
Selector inteligente de samples (Fase 4 mejorada).
|
|
|
|
Proporciona selección contextual basada en:
|
|
- Género musical
|
|
- Tonalidad (key) y compatibilidad armónica
|
|
- BPM y tempo
|
|
- Estilo y características
|
|
|
|
Mejoras Fase 4:
|
|
- Ranking multi-factor con scoring vectorizado
|
|
- Seeding determinista para reproducibilidad
|
|
- Validación de roles para evitar elecciones absurdas
|
|
- Penalización de familias repetidas
|
|
- Balance one-shots vs loops
|
|
"""
|
|
|
|
def __init__(self, manager: Optional[SampleManager] = None, session_seed: Optional[int] = None):
|
|
"""
|
|
Inicializa el selector.
|
|
|
|
Args:
|
|
manager: Instancia de SampleManager (usa global si None)
|
|
session_seed: Semilla para reproducibilidad dentro de una sesión
|
|
"""
|
|
if manager is None and MANAGER_AVAILABLE:
|
|
manager = get_manager()
|
|
|
|
self.manager = manager
|
|
self.analyzer = AudioAnalyzer() if MANAGER_AVAILABLE else None
|
|
|
|
# Historial de samples usados (ID -> timestamp)
|
|
self._recent_sample_ids = deque(maxlen=100)
|
|
# Historial de familias usadas (family -> count)
|
|
self._recent_families = defaultdict(int)
|
|
# Historial de roles usados (role -> [sample_ids])
|
|
self._role_history = defaultdict(list)
|
|
|
|
# Tracking de cooldown para familias
|
|
self._family_last_used: Dict[str, int] = {} # family -> selection_index
|
|
self._selection_counter: int = 0 # Increment each time a sample is selected
|
|
|
|
# Semilla de sesión para diversidad controlada
|
|
self._session_seed = session_seed or int(time.time() * 1000) % (2**31)
|
|
|
|
# Preferencias de balance one-shot vs loop
|
|
self._oneshot_preference = 0.7 # 70% preferencia one-shots para drums
|
|
self._loop_preference = 0.6 # 60% preferencia loops para synths
|
|
|
|
# Configuración de GPU
|
|
self._use_gpu = GPU_AVAILABLE
|
|
if self._use_gpu:
|
|
logger.info("GPU disponible, usando aceleración para cálculos vectorizados")
|
|
|
|
# Decision logging
|
|
self._decision_log: list[SampleDecision] = []
|
|
self._log_decisions: bool = False # Por defecto False para no impactar performance
|
|
|
|
def _generate_selection_seed(self, context: str = "") -> int:
|
|
"""
|
|
Genera una semilla determinista para cada selección.
|
|
Combina session_seed, contador y contexto.
|
|
"""
|
|
self._selection_counter += 1
|
|
seed_data = f"{self._session_seed}_{self._selection_counter}_{context}"
|
|
return int(hashlib.md5(seed_data.encode()).hexdigest()[:8], 16)
|
|
|
|
def _calculate_sample_score(self,
|
|
sample: 'Sample',
|
|
target_key: Optional[str] = None,
|
|
target_bpm: Optional[float] = None,
|
|
target_role: Optional[str] = None,
|
|
target_genre: Optional[str] = None,
|
|
prefer_oneshot: Optional[bool] = None) -> float:
|
|
"""
|
|
Calcula un score completo para un sample basado en múltiples factores.
|
|
|
|
Factores:
|
|
- Rating del sample (peso: 0.15)
|
|
- Compatibilidad de key (peso: 0.20)
|
|
- Compatibilidad de BPM (peso: 0.15)
|
|
- Ajuste de género (peso: 0.10)
|
|
- Validación de rol (peso: 0.15)
|
|
- Penalización por repetición (peso: 0.10)
|
|
- Balance one-shot/loop (peso: 0.10)
|
|
- Energía y características (peso: 0.05)
|
|
|
|
Returns:
|
|
Score normalizado entre 0 y 1
|
|
"""
|
|
score = 0.0
|
|
weights = 0.0
|
|
|
|
# 1. Rating del sample (0-5 -> 0-1)
|
|
rating_score = min(1.0, (sample.rating or 0) / 5.0)
|
|
score += rating_score * 0.15
|
|
weights += 0.15
|
|
|
|
# 2. Compatibilidad de key
|
|
if target_key and sample.key:
|
|
if MANAGER_AVAILABLE:
|
|
key_compat = calculate_key_compatibility(target_key, sample.key)
|
|
else:
|
|
key_compat = 1.0 if sample.key == target_key else 0.5
|
|
score += key_compat * 0.20
|
|
weights += 0.20
|
|
elif target_key:
|
|
# Sin key info, score neutral
|
|
score += 0.5 * 0.20
|
|
weights += 0.20
|
|
|
|
# 3. Compatibilidad de BPM
|
|
if target_bpm and sample.bpm:
|
|
bpm_diff = abs(sample.bpm - target_bpm)
|
|
if bpm_diff == 0:
|
|
bpm_score = 1.0
|
|
elif bpm_diff <= 3:
|
|
bpm_score = 0.95
|
|
elif bpm_diff <= 6:
|
|
bpm_score = 0.85
|
|
elif bpm_diff <= 10:
|
|
bpm_score = 0.70
|
|
else:
|
|
bpm_score = max(0.2, 1.0 - (bpm_diff / 30))
|
|
score += bpm_score * 0.15
|
|
weights += 0.15
|
|
elif target_bpm:
|
|
score += 0.5 * 0.15
|
|
weights += 0.15
|
|
|
|
# 4. Ajuste de género
|
|
if target_genre and sample.genres:
|
|
genre_lower = target_genre.lower().replace(' ', '-')
|
|
sample_genres_lower = [g.lower().replace(' ', '-') for g in sample.genres]
|
|
if genre_lower in sample_genres_lower:
|
|
genre_score = 1.0
|
|
elif any(g in genre_lower or genre_lower in g for g in sample_genres_lower):
|
|
genre_score = 0.7
|
|
else:
|
|
genre_score = 0.3
|
|
score += genre_score * 0.10
|
|
weights += 0.10
|
|
|
|
# 5. Validación de rol (EVITA ELECCIONES ABSURDAS)
|
|
if target_role:
|
|
role_score = self._validate_sample_for_role(sample, target_role)
|
|
score += role_score * 0.15
|
|
weights += 0.15
|
|
|
|
# 6. Penalización por repetición reciente
|
|
repetition_penalty = self._calculate_repetition_penalty(sample)
|
|
score += repetition_penalty * 0.10
|
|
weights += 0.10
|
|
|
|
# 7. Balance one-shot vs loop
|
|
if prefer_oneshot is not None:
|
|
is_oneshot = _is_oneshot(sample)
|
|
if prefer_oneshot and is_oneshot:
|
|
balance_score = 0.9
|
|
elif not prefer_oneshot and not is_oneshot:
|
|
balance_score = 0.9
|
|
else:
|
|
balance_score = 0.5
|
|
score += balance_score * 0.10
|
|
weights += 0.10
|
|
|
|
# Bonus por tipo correcto (one-shot vs loop) para roles críticos
|
|
if target_role and target_role.lower() in ROLE_ONE_SHOT_PREFERENCE:
|
|
prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role.lower()]
|
|
is_oneshot = _is_oneshot(sample)
|
|
if prefers_oneshot == is_oneshot:
|
|
score *= 1.2 # 20% bonus por tipo correcto
|
|
weights += 0.1
|
|
|
|
# 8. Energía y características espectrales
|
|
if sample.rms_energy > 0:
|
|
# Preferir samples con buena energía (no muy bajos ni saturados)
|
|
energy_score = min(1.0, sample.rms_energy * 2)
|
|
score += energy_score * 0.05
|
|
weights += 0.05
|
|
|
|
# T017: Factor brightness_fit (peso 0.10)
|
|
brightness_score = self._calculate_brightness_fit(sample, target_role)
|
|
if brightness_score < 1.0:
|
|
score += brightness_score * 0.10
|
|
weights += 0.10
|
|
|
|
# 9. Cooldown por familia (penaliza familias recientemente usadas)
|
|
if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop']:
|
|
family = _extract_sample_family(sample.name)
|
|
cooldown_penalty = self._get_family_cooldown_penalty(family)
|
|
score *= cooldown_penalty
|
|
weights += 0.15 # Peso significativo para cooldown
|
|
if cooldown_penalty < 0.5:
|
|
logger.debug("COOLDOWN: family '%s' has cooldown penalty %.2f (used %d selections ago)",
|
|
family, cooldown_penalty, self._selection_counter - self._family_last_used.get(family, 0))
|
|
|
|
# 10. Cross-generation penalty para roles críticos
|
|
if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop', 'snare']:
|
|
family = _extract_sample_family(sample.name)
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
cross_penalty = self._get_cross_generation_penalty(family, sample_path, target_role.lower())
|
|
if cross_penalty < 1.0:
|
|
score *= cross_penalty
|
|
weights += 0.12
|
|
logger.debug("CROSS_GEN: family '%s' has cross-gen penalty %.2f for role '%s' (used in %d prev generations)",
|
|
family, cross_penalty, target_role.lower(), _cross_generation_family_memory.get(family, 0))
|
|
|
|
# T022: Factor de fatiga persistente (opcional - requiere integración con server.py)
|
|
# Este factor se aplica si el server.py pasa datos de fatiga al selector
|
|
if hasattr(self, '_fatigue_data') and target_role:
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
fatigue_factor = self._get_persistent_fatigue(sample_path, target_role.lower())
|
|
if fatigue_factor < 1.0:
|
|
score *= fatigue_factor
|
|
weights += 0.10
|
|
logger.debug("FATIGUE: sample '%s' has fatigue factor %.2f for role '%s'",
|
|
Path(sample_path).name, fatigue_factor, target_role.lower())
|
|
|
|
# T026: Palette bonus (integración con server.py)
|
|
if hasattr(self, '_palette_data') and target_role:
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
bus = self._role_to_bus(target_role.lower())
|
|
if bus and bus in self._palette_data:
|
|
anchor_folder = self._palette_data[bus]
|
|
palette_bonus = self._calculate_palette_bonus(sample_path, anchor_folder)
|
|
score *= palette_bonus
|
|
weights += 0.15
|
|
logger.debug("PALETTE: sample '%s' has palette bonus %.2f for bus '%s'",
|
|
Path(sample_path).name, palette_bonus, bus)
|
|
|
|
# Normalizar
|
|
return score / weights if weights > 0 else 0.5
|
|
|
|
def _validate_sample_for_role(self, sample: 'Sample', target_role: str) -> float:
|
|
"""
|
|
Valida si un sample es apropiado para un rol específico.
|
|
Retorna un score de 0 a 1, donde 0 significa "completamente inapropiado".
|
|
|
|
Esto EVITA ELECCIONES ABSURDAS como:
|
|
- Snare roll donde va clap
|
|
- Hi-hat donde va kick
|
|
- Vocal sample en drum kit
|
|
"""
|
|
target_role_lower = target_role.lower()
|
|
sample_name_lower = sample.name.lower()
|
|
sample_type_lower = (sample.sample_type or '').lower()
|
|
sample_subcat_lower = (sample.subcategory or '').lower()
|
|
sample_duration = getattr(sample, 'duration', None) or 0
|
|
|
|
# Check using old DRUM_ROLE_VALID_TYPES (legacy support)
|
|
valid_types = DRUM_ROLE_VALID_TYPES.get(target_role_lower, [])
|
|
for vtype in valid_types:
|
|
if vtype in sample_type_lower or sample_type_lower in vtype:
|
|
return 1.0
|
|
if vtype in sample_subcat_lower or sample_subcat_lower in vtype:
|
|
return 0.95
|
|
|
|
for vtype in valid_types:
|
|
if vtype in sample_name_lower:
|
|
return 0.9
|
|
|
|
# Check using ROLE_REQUIRED_KEYWORDS for required keywords validation
|
|
required_keywords = ROLE_REQUIRED_KEYWORDS.get(target_role_lower, [])
|
|
if required_keywords:
|
|
for kw in required_keywords:
|
|
if kw in sample_name_lower:
|
|
return 0.85
|
|
if kw in sample_type_lower:
|
|
return 0.80
|
|
|
|
duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0))
|
|
if sample_duration > 0 and duration_max < 999.0:
|
|
if duration_min <= sample_duration <= duration_max:
|
|
pass
|
|
elif sample_duration < duration_min:
|
|
return 0.25
|
|
elif sample_duration > duration_max:
|
|
return 0.20
|
|
|
|
if sample.category == 'drums':
|
|
return 0.30
|
|
|
|
exclusive_roles = {
|
|
'kick': ['vocal', 'bass', 'synth', 'pad', 'fx'],
|
|
'snare': ['vocal', 'bass', 'synth'],
|
|
'clap': ['vocal', 'bass', 'kick'],
|
|
'hat_closed': ['vocal', 'bass', 'kick'],
|
|
'hat_open': ['vocal', 'bass', 'kick'],
|
|
'bass_loop': ['drum', 'vocal', 'fx'],
|
|
'vocal_loop': ['drum', 'bass', 'kick'],
|
|
}
|
|
|
|
excluded = exclusive_roles.get(target_role_lower, [])
|
|
for excluded_type in excluded:
|
|
if excluded_type in sample_name_lower:
|
|
return 0.0
|
|
|
|
return 0.15
|
|
|
|
def _hard_reject_check(self, sample: 'Sample', target_role: str) -> tuple[bool, str]:
|
|
"""
|
|
Verifica rechazo duro para roles críticos.
|
|
|
|
Retorna (should_reject, reason) donde:
|
|
- should_reject: True si el sample debe ser rechazado completamente
|
|
- reason: string explicando por qué
|
|
|
|
Esto es más estricto que _validate_sample_for_role() y captura
|
|
casos que son claramente errores semánticos.
|
|
|
|
Mejorado para Problema #4:
|
|
- Integra ROLE_EXCLUSION_PATTERNS
|
|
- Logging detallado de rechazos
|
|
"""
|
|
target_role_lower = target_role.lower()
|
|
sample_name_lower = sample.name.lower()
|
|
sample_duration = getattr(sample, 'duration', None)
|
|
|
|
# 1. Check ROLE_EXCLUSION_PATTERNS (nuevo sistema endurecido)
|
|
excluded, exclusion_reason = _check_role_exclusion(sample.name, target_role)
|
|
if excluded:
|
|
logger.debug("HARD_REJECT (exclusion): %s for role '%s': %s",
|
|
sample.name, target_role, exclusion_reason)
|
|
return True, f"ROLE_EXCLUSION: {exclusion_reason}"
|
|
|
|
# 2. Check HARD_REJECT_PATTERNS (sistema existente)
|
|
if target_role_lower not in HARD_REJECT_PATTERNS:
|
|
# Fallback a rangos de duración si no hay patrones específicos
|
|
duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0))
|
|
if sample_duration and duration_max < 999.0:
|
|
if sample_duration < duration_min:
|
|
return True, f"duration {sample_duration:.1f}s below min {duration_min}s for {target_role}"
|
|
if sample_duration > duration_max:
|
|
return True, f"duration {sample_duration:.1f}s exceeds max {duration_max}s for {target_role}"
|
|
return False, ""
|
|
|
|
patterns = HARD_REJECT_PATTERNS[target_role_lower]
|
|
sample_type_lower = (sample.sample_type or '').lower()
|
|
sample_subcat_lower = (sample.subcategory or '').lower()
|
|
|
|
# Check excluded keywords
|
|
for kw in patterns.get('exclude_keywords', []):
|
|
if kw in sample_name_lower:
|
|
logger.debug("HARD_REJECT (keyword): %s for role '%s': contains '%s'",
|
|
sample.name, target_role, kw)
|
|
return True, f"contains excluded keyword '{kw}'"
|
|
|
|
# Check excluded subcategories
|
|
for subcat in patterns.get('exclude_subcategories', []):
|
|
if subcat in sample_subcat_lower or subcat in sample_type_lower:
|
|
logger.debug("HARD_REJECT (subcat): %s for role '%s': subcategory '%s'",
|
|
sample.name, target_role, subcat)
|
|
return True, f"has excluded subcategory '{subcat}'"
|
|
|
|
# Check duration constraints
|
|
max_duration = patterns.get('max_duration')
|
|
min_duration = patterns.get('min_duration')
|
|
if sample_duration:
|
|
if max_duration and sample_duration > max_duration:
|
|
logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs > max %.1fs",
|
|
sample.name, target_role, sample_duration, max_duration)
|
|
return True, f"duration {sample_duration:.1f}s exceeds max {max_duration}s"
|
|
if min_duration and sample_duration < min_duration:
|
|
logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs < min %.1fs",
|
|
sample.name, target_role, sample_duration, min_duration)
|
|
return True, f"duration {sample_duration:.1f}s below min {min_duration}s"
|
|
|
|
# Check must_contain requirements
|
|
must_contain = patterns.get('must_contain_one', [])
|
|
if must_contain:
|
|
found = any(kw in sample_name_lower or kw in sample_type_lower for kw in must_contain)
|
|
if not found:
|
|
logger.debug("HARD_REJECT (missing): %s for role '%s': needs one of %s",
|
|
sample.name, target_role, must_contain)
|
|
return True, f"does not contain any of: {must_contain}"
|
|
|
|
# Check must_contain_none keywords
|
|
for kw in patterns.get('must_contain_none', []):
|
|
if kw in sample_name_lower:
|
|
logger.debug("HARD_REJECT (forbidden): %s for role '%s': contains '%s'",
|
|
sample.name, target_role, kw)
|
|
return True, f"contains excluded keyword '{kw}'"
|
|
|
|
return False, ""
|
|
|
|
|
|
def _validate_loop_preference(self, sample: 'Sample', target_role: str) -> tuple[bool, str]:
|
|
"""
|
|
Valida preferencia de one-shot vs loop para roles críticos.
|
|
|
|
Retorna (is_valid, reason) donde:
|
|
- is_valid: True si el sample cumple la preferencia
|
|
- reason: string explicando violación si aplica
|
|
"""
|
|
target_role_lower = target_role.lower()
|
|
|
|
if target_role_lower not in ROLE_ONE_SHOT_PREFERENCE:
|
|
return True, "" # No hay preferencia definida
|
|
|
|
prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role_lower]
|
|
is_oneshot = _is_oneshot(sample)
|
|
|
|
if prefers_oneshot and not is_oneshot:
|
|
return False, f"role requires one-shot but sample is loop (duration={sample.duration:.1f}s)"
|
|
elif not prefers_oneshot and is_oneshot:
|
|
return False, f"role requires loop but sample is one-shot (duration={sample.duration:.1f}s)"
|
|
|
|
return True, ""
|
|
|
|
def _calculate_brightness_fit(self, sample: 'Sample', target_role: Optional[str]) -> float:
|
|
"""
|
|
T017: Calcula ajuste de brillo espectral para el rol objetivo.
|
|
|
|
Retorna score 0-1 donde 1.0 = perfecto ajuste, <1.0 = penalización aplicada.
|
|
|
|
Reglas:
|
|
- atmos, pad, drone: penalizar spectral_centroid > 8000 Hz (demasiado brillante)
|
|
- bass, sub_bass: penalizar spectral_centroid > 3000 Hz (pierde sub)
|
|
- lead, chord: sin penalización por brillo, pero preferir centrado medio
|
|
"""
|
|
if not target_role:
|
|
return 1.0
|
|
|
|
target_role_lower = target_role.lower()
|
|
|
|
# Obtener spectral_centroid del sample (si está disponible)
|
|
spectral_centroid = getattr(sample, 'spectral_centroid', None) or 5000.0
|
|
|
|
# Roles que prefieren sonidos oscuros/cálidos
|
|
dark_preferred_roles = ['atmos', 'pad', 'drone', 'ambience', 'texture']
|
|
if any(r in target_role_lower for r in dark_preferred_roles):
|
|
if spectral_centroid > 8000:
|
|
# Penalización progresiva: >8000 = 0.5, >10000 = 0.3
|
|
return max(0.3, 1.0 - (spectral_centroid - 8000) / 4000)
|
|
elif spectral_centroid > 6000:
|
|
return 0.8
|
|
else:
|
|
return 1.0
|
|
|
|
# Roles de bajo que necesitan contenido de graves
|
|
bass_roles = ['bass', 'sub_bass', 'bassline', '808', 'sub']
|
|
if any(r in target_role_lower for r in bass_roles):
|
|
if spectral_centroid > 3000:
|
|
# Penalización severa para bass sin graves
|
|
return max(0.2, 1.0 - (spectral_centroid - 3000) / 2000)
|
|
elif spectral_centroid > 1500:
|
|
return 0.7
|
|
else:
|
|
return 1.0
|
|
|
|
# Roles brillantes permitidos
|
|
bright_roles = ['lead', 'chord', 'stab', 'pluck', 'arp', 'synth']
|
|
if any(r in target_role_lower for r in bright_roles):
|
|
# Preferir rango medio-alto, no demasiado brillante ni opaco
|
|
if 2000 <= spectral_centroid <= 8000:
|
|
return 1.0
|
|
elif spectral_centroid < 1000:
|
|
return 0.7 # Quizás demasiado opaco
|
|
elif spectral_centroid > 12000:
|
|
return 0.8 # Quizás demasiado brillante/agudo
|
|
else:
|
|
return 0.9
|
|
|
|
# Default: sin penalización
|
|
return 1.0
|
|
|
|
def set_fatigue_data(self, fatigue_data: Dict[str, Dict[str, Any]]) -> None:
|
|
"""
|
|
T022: Carga datos de fatiga persistente desde server.py.
|
|
Permite que el selector aplique penalización por uso previo.
|
|
"""
|
|
self._fatigue_data = fatigue_data
|
|
logger.debug(f"Fatigue data cargada: {len(fatigue_data)} samples")
|
|
|
|
def _get_persistent_fatigue(self, sample_path: str, role: str) -> float:
|
|
"""
|
|
T022: Obtiene factor de fatiga persistente para un sample y rol.
|
|
|
|
Retorna:
|
|
- 1.0: Sin fatiga (0 usos)
|
|
- 0.75: Fatiga ligera (1-3 usos)
|
|
- 0.50: Fatiga moderada (4-10 usos)
|
|
- 0.20: Fatiga severa (10+ usos)
|
|
"""
|
|
if not hasattr(self, '_fatigue_data') or not self._fatigue_data:
|
|
return 1.0
|
|
|
|
sample_fatigue = self._fatigue_data.get(sample_path, {})
|
|
role_data = sample_fatigue.get(role, {})
|
|
uses = role_data.get("uses", 0)
|
|
|
|
if uses == 0:
|
|
return 1.0
|
|
elif 1 <= uses <= 3:
|
|
return 0.75
|
|
elif 4 <= uses <= 10:
|
|
return 0.50
|
|
else:
|
|
return 0.20
|
|
|
|
def set_palette_data(self, palette_data: Dict[str, str]) -> None:
|
|
"""
|
|
T026: Carga datos de palette desde server.py.
|
|
Permite aplicar bonus/penalización por compatibilidad con ancla.
|
|
"""
|
|
self._palette_data = palette_data
|
|
logger.debug(f"Palette data cargada: {palette_data}")
|
|
|
|
def _role_to_bus(self, role: str) -> Optional[str]:
|
|
"""Mapea un rol a su bus correspondiente."""
|
|
bus_mapping = {
|
|
'kick': 'drums', 'clap': 'drums', 'hat': 'drums', 'snare': 'drums',
|
|
'perc': 'drums', 'top_loop': 'drums', 'drum_loop': 'drums',
|
|
'bass': 'bass', 'sub_bass': 'bass', 'bass_loop': 'bass', '808': 'bass',
|
|
'synth': 'music', 'pad': 'music', 'lead': 'music', 'chord': 'music',
|
|
'arp': 'music', 'pluck': 'music', 'synth_loop': 'music',
|
|
'vocal': 'vocal', 'vocal_loop': 'vocal', 'vox': 'vocal',
|
|
'fx': 'fx', 'riser': 'fx', 'impact': 'fx', 'atmos': 'fx'
|
|
}
|
|
return bus_mapping.get(role.lower())
|
|
|
|
def _calculate_palette_bonus(self, sample_path: str, anchor_folder: str) -> float:
|
|
"""
|
|
T026: Calcula bonus por compatibilidad con folder ancla.
|
|
|
|
- Folder exacto: 1.4x
|
|
- Subfolder del ancla: 1.3x
|
|
- Folder hermano (mismo padre): 1.2x
|
|
- Diferente: 0.9x
|
|
"""
|
|
import os
|
|
if not anchor_folder:
|
|
return 1.0
|
|
|
|
# Normalize paths to use forward slashes
|
|
sample_folder = str(Path(sample_path).parent).replace(os.sep, '/')
|
|
anchor = anchor_folder.replace(os.sep, '/')
|
|
|
|
# Match exacto
|
|
if sample_folder == anchor:
|
|
return 1.4
|
|
|
|
# Subfolder del ancla
|
|
if sample_folder.startswith(anchor + '/'):
|
|
return 1.3
|
|
|
|
# Mismo padre (hermano)
|
|
sample_parent = str(Path(sample_folder).parent).replace(os.sep, '/')
|
|
anchor_parent = str(Path(anchor).parent).replace(os.sep, '/')
|
|
if sample_parent == anchor_parent:
|
|
return 1.2
|
|
|
|
# Diferente
|
|
return 0.9
|
|
|
|
def _calculate_repetition_penalty(self, sample: 'Sample') -> float:
|
|
"""
|
|
Calcula penalización por repetición de sample y familia.
|
|
Retorna 1.0 (sin penalización) a 0.1 (penalización máxima).
|
|
"""
|
|
penalty = 1.0
|
|
|
|
# Penalizar sample ya usado
|
|
if getattr(sample, "id", None) in self._recent_sample_ids:
|
|
penalty *= 0.3
|
|
|
|
# Penalizar familia repetida
|
|
family = _extract_sample_family(sample.name)
|
|
family_count = self._recent_families.get(family, 0)
|
|
if family_count > 0:
|
|
# Penalización decreciente: 0.85, 0.70, 0.55, ...
|
|
penalty *= max(0.3, 1.0 - (family_count * 0.15))
|
|
|
|
return penalty
|
|
|
|
def _remember_sample(self, sample: Optional['Sample'], role: str = None) -> None:
|
|
"""Registra un sample como usado para evitar repeticiones.
|
|
|
|
Ahora integra con diversity_memory.py para persistencia cross-generation.
|
|
"""
|
|
if sample is not None and getattr(sample, "id", None):
|
|
self._recent_sample_ids.append(sample.id)
|
|
family = _extract_sample_family(sample.name)
|
|
self._recent_families[family] += 1
|
|
|
|
# Track para esta generación específica
|
|
if hasattr(self, '_generation_families'):
|
|
self._generation_families[family] += 1
|
|
|
|
# Track path para cross-generation memory
|
|
if hasattr(self, '_generation_paths'):
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
if sample_path:
|
|
self._generation_paths[sample_path] += 1
|
|
|
|
# Track para cooldown (dentro de generación)
|
|
self._selection_counter += 1
|
|
self._family_last_used[family] = self._selection_counter
|
|
|
|
# Add to recent sample diversity memory
|
|
if role:
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
if sample_path:
|
|
add_to_recent_memory(role, sample_path)
|
|
|
|
# REGISTRAR EN MEMORIA PERSISTENTE (diversity_memory.py)
|
|
# Solo para roles críticos para evitar overhead excesivo
|
|
if role and DIVERSITY_MEMORY_AVAILABLE:
|
|
try:
|
|
sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
|
|
if sample_path:
|
|
record_sample_usage(role, sample_path, sample.name)
|
|
except Exception as e:
|
|
logger.debug("Error registrando sample en memoria persistente: %s", e)
|
|
|
|
def _get_family_cooldown_penalty(self, family: str) -> float:
|
|
"""
|
|
Calcula penalización por cooldown de familia.
|
|
|
|
Retorna 1.0 (sin penalización) a 0.0 (penalización máxima - rechazo duro).
|
|
|
|
Las familias recientemente usadas tienen penalización progresiva:
|
|
- Usado hace 0 selecciones: 0.0 (rechazo duro - no reusable inmediatamente)
|
|
- Usado hace 1 selección: 0.20
|
|
- Usado hace 2 selecciones: 0.40
|
|
- Usado hace 3 selecciones: 0.55
|
|
- Usado hace 4 selecciones: 0.70
|
|
- Usado hace 5 selecciones: 0.85
|
|
- Usado hace COOLDOWN_WINDOW o más: 1.0 (sin penalización)
|
|
"""
|
|
if family not in self._family_last_used:
|
|
return 1.0
|
|
|
|
selections_ago = self._selection_counter - self._family_last_used[family]
|
|
|
|
if selections_ago <= 0:
|
|
return 0.0
|
|
elif selections_ago == 1:
|
|
return 0.20
|
|
elif selections_ago == 2:
|
|
return 0.40
|
|
elif selections_ago == 3:
|
|
return 0.55
|
|
elif selections_ago == 4:
|
|
return 0.70
|
|
elif selections_ago == 5:
|
|
return 0.85
|
|
elif selections_ago >= COOLDOWN_WINDOW:
|
|
return 1.0
|
|
else:
|
|
return min(1.0, 0.20 + (selections_ago / COOLDOWN_WINDOW) * 0.80)
|
|
|
|
def _get_cross_generation_penalty(self, family: str, path: str = None, role: str = None) -> float:
|
|
"""
|
|
Calcula penalización por uso en generaciones anteriores.
|
|
|
|
Retorna factor de penalty (0.0 - 1.0) basado en uso reciente.
|
|
|
|
Ahora integra con diversity_memory.py para penalización persistente
|
|
de familias para roles críticos.
|
|
"""
|
|
# PRIMERO: Usar sistema persistente si está disponible y es rol crítico
|
|
if role and DIVERSITY_MEMORY_AVAILABLE:
|
|
try:
|
|
persistent_penalty = get_penalty_for_sample(role, path or '', '')
|
|
if persistent_penalty < 1.0:
|
|
logger.debug("CROSS_GEN (persistent): family penalty for role '%s': %.2f",
|
|
role, persistent_penalty)
|
|
return persistent_penalty
|
|
except Exception as e:
|
|
logger.debug("Error obteniendo penalización persistente: %s", e)
|
|
|
|
# FALLBACK: Usar memoria en RAM (legacy)
|
|
family_penalty = 1.0
|
|
cross_gen_count = _cross_generation_family_memory.get(family, 0)
|
|
if cross_gen_count >= 4:
|
|
family_penalty = 0.08
|
|
elif cross_gen_count >= 3:
|
|
family_penalty = 0.20
|
|
elif cross_gen_count >= 2:
|
|
family_penalty = 0.40
|
|
elif cross_gen_count >= 1:
|
|
family_penalty = 0.70
|
|
|
|
path_penalty = 1.0
|
|
if path and path in _cross_generation_path_memory:
|
|
path_count = _cross_generation_path_memory.get(path, 0)
|
|
if path_count >= 3:
|
|
path_penalty = 0.05
|
|
elif path_count >= 2:
|
|
path_penalty = 0.15
|
|
else:
|
|
path_penalty = 0.35
|
|
|
|
recent_role_penalty = 1.0
|
|
if role and path:
|
|
recent_role_penalty = get_recent_memory_penalty(role, path)
|
|
|
|
return family_penalty * path_penalty * recent_role_penalty
|
|
|
|
def _apply_suspicion_penalty(self, score: float, sample_name: str, role: str) -> float:
|
|
"""
|
|
Aplica penalty a samples con nombres sospechosos para el rol.
|
|
|
|
A diferencia de HARD_REJECT_PATTERNS, esto es un penalty suave
|
|
que reduce el score pero no elimina completamente el candidato.
|
|
|
|
Args:
|
|
score: Score base del sample
|
|
sample_name: Nombre del sample
|
|
role: Rol objetivo
|
|
|
|
Returns:
|
|
Score ajustado con penalty aplicado
|
|
"""
|
|
role_lower = role.lower() if role else ""
|
|
if role_lower not in SUSPICIOUS_KEYWORDS:
|
|
return score
|
|
|
|
name_lower = sample_name.lower()
|
|
suspicious = SUSPICIOUS_KEYWORDS[role_lower]
|
|
|
|
penalty = 1.0
|
|
for kw in suspicious:
|
|
if kw in name_lower:
|
|
penalty *= 0.7 # 30% penalty per suspicious keyword found
|
|
|
|
return score * penalty
|
|
|
|
def _break_tie_randomized(self, candidates: List[Dict], seed_base: str = "") -> List[Dict]:
|
|
"""
|
|
Rompe empates con jitter determinista basado en hash.
|
|
|
|
Cuando los scores son muy cercanos (dentro del 5%), usa randomización
|
|
determinista para evitar que siempre gane el mismo candidato.
|
|
|
|
Args:
|
|
candidates: Lista de dicts con 'score' o 'final_score' y 'sample'
|
|
seed_base: String base para el seed determinista
|
|
|
|
Returns:
|
|
Lista reordenada con empates rotos
|
|
"""
|
|
if len(candidates) <= 1:
|
|
return candidates
|
|
|
|
# Group by similar scores (within 5%)
|
|
result = []
|
|
i = 0
|
|
while i < len(candidates):
|
|
# Find all candidates with similar scores
|
|
current_score = candidates[i].get('final_score', candidates[i].get('score', 0))
|
|
group = [candidates[i]]
|
|
j = i + 1
|
|
while j < len(candidates):
|
|
other_score = candidates[j].get('final_score', candidates[j].get('score', 0))
|
|
if abs(current_score - other_score) / max(current_score, other_score, 0.001) < 0.05:
|
|
group.append(candidates[j])
|
|
j += 1
|
|
else:
|
|
break
|
|
|
|
if len(group) > 1:
|
|
# Shuffle group deterministically based on names
|
|
sample_names = ""
|
|
for c in group:
|
|
sample = c.get('sample')
|
|
if sample:
|
|
sample_names += getattr(sample, 'name', '')
|
|
seed = int(hashlib.md5((seed_base + sample_names).encode()).hexdigest()[:8], 16)
|
|
rng = random.Random(seed)
|
|
rng.shuffle(group)
|
|
|
|
result.extend(group)
|
|
i = j
|
|
|
|
return result
|
|
|
|
def reset_cooldown_tracking(self) -> None:
|
|
"""Resetea el tracking de cooldown para nueva generación."""
|
|
self._family_last_used.clear()
|
|
self._selection_counter = 0
|
|
self._recent_families.clear()
|
|
self._recent_sample_ids.clear()
|
|
|
|
def start_generation_tracking(self) -> None:
|
|
"""Marca el inicio de una nueva generación (llamar al inicio de generate_track)."""
|
|
self._generation_families = defaultdict(int)
|
|
self._generation_paths: Dict[str, int] = defaultdict(int)
|
|
|
|
def end_generation_tracking(self) -> None:
|
|
"""Marca el fin de una generación y actualiza memoria cross-generation."""
|
|
if hasattr(self, '_generation_families'):
|
|
paths_used = list(self._generation_paths.keys()) if hasattr(self, '_generation_paths') else []
|
|
_update_cross_generation_memory(self._generation_families, paths_used)
|
|
delattr(self, '_generation_families')
|
|
if hasattr(self, '_generation_paths'):
|
|
delattr(self, '_generation_paths')
|
|
|
|
def _log_decision(self, decision: SampleDecision) -> None:
|
|
"""Registra una decisión si logging está activado."""
|
|
if self._log_decisions:
|
|
self._decision_log.append(decision)
|
|
logger.debug("SAMPLE_DECISION: %s", decision.to_log_str())
|
|
|
|
def _pick_ranked_sample(self,
|
|
samples: List['Sample'],
|
|
target_key: Optional[str] = None,
|
|
target_bpm: Optional[float] = None,
|
|
target_role: Optional[str] = None,
|
|
target_genre: Optional[str] = None,
|
|
prefer_oneshot: Optional[bool] = None,
|
|
pool_size: int = 12,
|
|
context: str = "") -> Optional['Sample']:
|
|
"""
|
|
Selecciona un sample usando ranking multi-factor con weighted random.
|
|
|
|
Args:
|
|
samples: Lista de samples candidatos
|
|
target_key: Key objetivo para matching armónico
|
|
target_bpm: BPM objetivo para matching de tempo
|
|
target_role: Rol objetivo para validación (ej: 'kick', 'clap')
|
|
target_genre: Género objetivo
|
|
prefer_oneshot: Preferencia por one-shot (True) o loop (False)
|
|
pool_size: Tamaño del pool de mejores candidatos
|
|
context: Contexto para seeding determinista
|
|
|
|
Returns:
|
|
Sample seleccionado o None si no hay candidatos
|
|
"""
|
|
if not samples:
|
|
return None
|
|
|
|
# Calcular scores para todos los samples
|
|
scored_samples = []
|
|
for sample in samples:
|
|
score = self._calculate_sample_score(
|
|
sample,
|
|
target_key=target_key,
|
|
target_bpm=target_bpm,
|
|
target_role=target_role,
|
|
target_genre=target_genre,
|
|
prefer_oneshot=prefer_oneshot
|
|
)
|
|
# Apply suspicion penalty for samples with suspicious names
|
|
if target_role:
|
|
score = self._apply_suspicion_penalty(score, sample.name, target_role)
|
|
scored_samples.append({'score': score, 'sample': sample, 'rejection_reasons': []})
|
|
|
|
# Ordenar por score descendente
|
|
scored_samples.sort(key=lambda x: x['score'], reverse=True)
|
|
|
|
# Apply tie-breaking with deterministic randomization
|
|
scored_samples = self._break_tie_randomized(scored_samples, context)
|
|
|
|
# Filtrar por rechazo duro para roles críticos
|
|
if target_role:
|
|
filtered_samples = []
|
|
for s in scored_samples:
|
|
should_reject, reason = self._hard_reject_check(s['sample'], target_role)
|
|
if should_reject:
|
|
s['rejection_reasons'].append(f"hard_reject: {reason}")
|
|
logger.debug("HARD_REJECT: %s for role '%s': %s", s['sample'].name, target_role, reason)
|
|
else:
|
|
filtered_samples.append(s)
|
|
scored_samples = filtered_samples
|
|
|
|
if not scored_samples:
|
|
logger.warning("All samples hard-rejected for role '%s', using fallback", target_role)
|
|
# Validar preferencia one-shot/loop para roles críticos
|
|
if target_role:
|
|
filtered_samples = []
|
|
for s in scored_samples:
|
|
is_valid, reason = self._validate_loop_preference(s['sample'], target_role)
|
|
if not is_valid:
|
|
s['rejection_reasons'].append(f"loop_pref: {reason}")
|
|
logger.debug("LOOP_PREF: rejecting %s for role '%s': %s", s['sample'].name, target_role, reason)
|
|
else:
|
|
filtered_samples.append(s)
|
|
scored_samples = filtered_samples
|
|
|
|
if not scored_samples:
|
|
logger.warning("All samples rejected by loop preference for role '%s'", target_role)
|
|
|
|
|
|
# Tomar top pool_size candidatos
|
|
top_samples = scored_samples[:max(1, min(pool_size, len(scored_samples)))]
|
|
|
|
# Aplicar jitter con seeding determinista
|
|
selection_seed = self._generate_selection_seed(context)
|
|
rng = random.Random(selection_seed)
|
|
|
|
# Weighted random selection con jitter
|
|
weighted: List[Tuple[float, 'Sample']] = []
|
|
for rank, s in enumerate(top_samples):
|
|
score = s['score']
|
|
sample = s['sample']
|
|
# Decaimiento por posición en el ranking
|
|
rank_weight = max(0.2, 1.0 - (rank * 0.07))
|
|
# Jitter aleatorio
|
|
jitter = 0.85 + (rng.random() * 0.30)
|
|
final_weight = max(0.01, score * rank_weight * jitter)
|
|
weighted.append((final_weight, sample))
|
|
|
|
# Selección por weighted random
|
|
if NUMPY_AVAILABLE and len(weighted) > 3:
|
|
# Usar numpy para mejor performance
|
|
weights = np.array([w for w, _ in weighted])
|
|
weights = weights / weights.sum()
|
|
idx = np.random.default_rng(selection_seed).choice(len(weighted), p=weights)
|
|
selected = weighted[idx][1]
|
|
final_score = weighted[idx][0]
|
|
selected_idx = idx
|
|
else:
|
|
# Fallback a random estándar
|
|
total = sum(weight for weight, _ in weighted)
|
|
pivot = rng.random() * total
|
|
running = 0.0
|
|
selected = weighted[0][1] # default
|
|
final_score = weighted[0][0]
|
|
selected_idx = 0
|
|
for idx, (weight, sample) in enumerate(weighted):
|
|
running += weight
|
|
if pivot <= running:
|
|
selected = sample
|
|
final_score = weight
|
|
selected_idx = idx
|
|
break
|
|
|
|
self._remember_sample(selected, role=target_role)
|
|
|
|
# Log decision if enabled
|
|
if self._log_decisions and selected:
|
|
# Determine bonus factors (would need to be tracked during scoring)
|
|
bonus_list = []
|
|
|
|
# Log the selected sample
|
|
decision = SampleDecision(
|
|
sample_name=selected.name,
|
|
target_role=target_role or "unknown",
|
|
final_score=final_score,
|
|
selected=True,
|
|
selection_index=selected_idx,
|
|
bonus_factors=bonus_list
|
|
)
|
|
self._log_decision(decision)
|
|
|
|
# Also log top 5 rejections
|
|
for idx, s in enumerate(scored_samples[:5]): # Top 5 rejected
|
|
if s['sample'].name != selected.name:
|
|
reject_decision = SampleDecision(
|
|
sample_name=s['sample'].name,
|
|
target_role=target_role or "unknown",
|
|
final_score=s['score'],
|
|
selected=False,
|
|
selection_index=idx,
|
|
rejection_reasons=s.get('rejection_reasons', [])
|
|
)
|
|
self._log_decision(reject_decision)
|
|
|
|
return selected
|
|
|
|
def _pick_multiple_ranked(self,
|
|
samples: List['Sample'],
|
|
count: int,
|
|
target_key: Optional[str] = None,
|
|
target_bpm: Optional[float] = None,
|
|
target_role: Optional[str] = None,
|
|
target_genre: Optional[str] = None,
|
|
prefer_oneshot: Optional[bool] = None,
|
|
pool_size: int = 15,
|
|
context: str = "") -> List['Sample']:
|
|
"""
|
|
Selecciona múltiples samples con diversidad garantizada.
|
|
"""
|
|
chosen: List['Sample'] = []
|
|
if not samples or count <= 0:
|
|
return chosen
|
|
|
|
remaining = list(samples)
|
|
seen_ids = set()
|
|
sub_context = context
|
|
|
|
while remaining and len(chosen) < count:
|
|
selected = self._pick_ranked_sample(
|
|
remaining,
|
|
target_key=target_key,
|
|
target_bpm=target_bpm,
|
|
target_role=target_role,
|
|
target_genre=target_genre,
|
|
prefer_oneshot=prefer_oneshot,
|
|
pool_size=pool_size,
|
|
context=f"{sub_context}_{len(chosen)}"
|
|
)
|
|
if selected is None:
|
|
break
|
|
if selected.id not in seen_ids:
|
|
chosen.append(selected)
|
|
seen_ids.add(selected.id)
|
|
remaining = [sample for sample in remaining if sample.id != selected.id]
|
|
|
|
return chosen
|
|
|
|
def get_decision_log(self) -> list[SampleDecision]:
|
|
"""Retorna el log de decisiones acumulado."""
|
|
return self._decision_log.copy()
|
|
|
|
def clear_decision_log(self) -> None:
|
|
"""Limpia el log de decisiones."""
|
|
self._decision_log.clear()
|
|
|
|
def enable_decision_logging(self, enabled: bool = True) -> None:
|
|
"""Activa/desactiva logging de decisiones."""
|
|
self._log_decisions = enabled
|
|
|
|
def select_for_genre(self,
|
|
genre: str,
|
|
key: Optional[str] = None,
|
|
bpm: Optional[float] = None,
|
|
variation: str = "standard",
|
|
session_seed: Optional[int] = None) -> InstrumentGroup:
|
|
"""
|
|
Selecciona un grupo completo de instrumentos para un género.
|
|
|
|
Args:
|
|
genre: Género musical
|
|
key: Tonalidad preferida (auto-selecciona si None)
|
|
bpm: BPM preferido (auto-selecciona si None)
|
|
variation: Variación del estilo
|
|
session_seed: Semilla para reproducibilidad (actualiza si se provee)
|
|
|
|
Returns:
|
|
InstrumentGroup con samples seleccionados
|
|
"""
|
|
# Actualizar semilla de sesión si se provee
|
|
if session_seed is not None:
|
|
self._session_seed = session_seed
|
|
self._selection_counter = 0
|
|
|
|
# Normalizar género
|
|
genre_profile = self._get_genre_profile(genre)
|
|
|
|
# Seleccionar key si no se especificó (con seeding determinista)
|
|
if key is None:
|
|
rng = random.Random(self._generate_selection_seed("key"))
|
|
key = rng.choice(genre_profile.common_keys)
|
|
|
|
# Seleccionar BPM si no se especificó (con seeding determinista)
|
|
if bpm is None:
|
|
rng = random.Random(self._generate_selection_seed("bpm"))
|
|
bpm = rng.randint(genre_profile.bpm_range[0], genre_profile.bpm_range[1])
|
|
|
|
# Crear grupo
|
|
group = InstrumentGroup(
|
|
genre=genre_profile.name,
|
|
key=key,
|
|
bpm=float(bpm)
|
|
)
|
|
|
|
# Seleccionar drums CON validación de roles
|
|
group.drums = self._select_drum_kit(genre, variation, target_key=key)
|
|
|
|
# Seleccionar bass con matching armónico
|
|
group.bass = self._select_bass_samples(genre, key, bpm, count=3)
|
|
|
|
# Seleccionar synths con diversidad
|
|
group.synths = self._select_synth_samples(genre, key, bpm, count=3)
|
|
|
|
# Seleccionar FX
|
|
group.fx = self._select_fx_samples(genre, count=2, target_bpm=bpm)
|
|
|
|
return group
|
|
|
|
def _get_genre_profile(self, genre: str) -> GenreProfile:
|
|
"""Obtiene el perfil de un género"""
|
|
genre_lower = genre.lower().replace(' ', '-')
|
|
|
|
# Búsqueda exacta
|
|
if genre_lower in GENRE_PROFILES:
|
|
return GENRE_PROFILES[genre_lower]
|
|
|
|
# Búsqueda parcial
|
|
for name, profile in GENRE_PROFILES.items():
|
|
if genre_lower in name or name in genre_lower:
|
|
return profile
|
|
|
|
# Fallback a techno
|
|
logger.warning(f"Género '{genre}' no encontrado, usando techno")
|
|
return GENRE_PROFILES['techno']
|
|
|
|
def _select_drum_kit(self, genre: str, variation: str = "standard", target_key: Optional[str] = None) -> DrumKit:
|
|
"""
|
|
Selecciona un kit de batería coherente con validación de roles.
|
|
|
|
Mejoras Fase 4:
|
|
- Valida que cada sample sea apropiado para su rol
|
|
- Penaliza samples inapropiados (ej: snare en rol clap)
|
|
- Balancea entre one-shots preferentemente
|
|
"""
|
|
if not self.manager:
|
|
return DrumKit(name="empty")
|
|
|
|
kit = DrumKit(name=f"{genre}_{variation}")
|
|
|
|
# Función mejorada para encontrar drums con validación de rol
|
|
def find_drum(drum_role: str, keywords: List[str], prefer_oneshot: bool = True) -> Optional[Sample]:
|
|
all_results = []
|
|
|
|
# Buscar con múltiples keywords y acumular
|
|
for keyword in keywords:
|
|
results = self.manager.search(
|
|
query=keyword,
|
|
category="drums",
|
|
limit=50
|
|
)
|
|
all_results.extend(results)
|
|
|
|
# Eliminar duplicados
|
|
seen_ids = set()
|
|
unique_results = []
|
|
for s in all_results:
|
|
if s.id not in seen_ids:
|
|
seen_ids.add(s.id)
|
|
unique_results.append(s)
|
|
|
|
if not unique_results:
|
|
return None
|
|
|
|
# Usar el selector mejorado con validación de rol
|
|
return self._pick_ranked_sample(
|
|
unique_results,
|
|
target_key=target_key,
|
|
target_role=drum_role, # Validación de rol
|
|
target_genre=genre,
|
|
prefer_oneshot=prefer_oneshot,
|
|
pool_size=12,
|
|
context=f"drum_{drum_role}"
|
|
)
|
|
|
|
# Kick - siempre one-shot
|
|
kit.kick = find_drum("kick", ["kick", "bd", "bass_drum"], prefer_oneshot=True)
|
|
|
|
# Snare o Clap según género - CON VALIDACIÓN DE ROL
|
|
if genre in ['house', 'tech-house', 'deep-house']:
|
|
# En house, clap es más común que snare
|
|
kit.clap = find_drum("clap", ["clap", "handclap"], prefer_oneshot=True)
|
|
kit.snare = find_drum("snare", ["snare", "rim"], prefer_oneshot=True)
|
|
else:
|
|
# En techno, snare es más común
|
|
kit.snare = find_drum("snare", ["snare", "rimshot"], prefer_oneshot=True)
|
|
kit.clap = find_drum("clap", ["clap"], prefer_oneshot=True)
|
|
|
|
# Hats - validar que sean realmente hats
|
|
kit.hat_closed = find_drum("hat_closed", ["closed hat", "hihat", "hat"], prefer_oneshot=True)
|
|
kit.hat_open = find_drum("hat_open", ["open hat", "ohh"], prefer_oneshot=True)
|
|
|
|
# Percusión adicional - validar roles
|
|
kit.perc1 = find_drum("perc", ["perc", "shaker", "tamb"], prefer_oneshot=True)
|
|
kit.perc2 = find_drum("perc", ["percussion", "conga", "bongo"], prefer_oneshot=True)
|
|
|
|
# Tom
|
|
kit.tom = find_drum("tom", ["tom", "tomtom"], prefer_oneshot=True)
|
|
|
|
# Crash (opcional)
|
|
kit.crash = find_drum("crash", ["crash", "cymbal"], prefer_oneshot=True)
|
|
|
|
# Registrar roles usados
|
|
if kit.kick:
|
|
self._role_history['kick'].append(kit.kick.id)
|
|
if kit.snare:
|
|
self._role_history['snare'].append(kit.snare.id)
|
|
if kit.clap:
|
|
self._role_history['clap'].append(kit.clap.id)
|
|
|
|
return kit
|
|
|
|
def _select_bass_samples(self,
|
|
genre: str,
|
|
key: str,
|
|
bpm: float,
|
|
count: int = 3) -> List[Sample]:
|
|
"""
|
|
Selecciona samples de bajo compatibles con mejor ranking.
|
|
|
|
Mejoras Fase 4:
|
|
- Matching armónico mejorado
|
|
- Balance one-shot vs loop según contexto
|
|
- Penalización de familias repetidas
|
|
"""
|
|
if not self.manager:
|
|
return []
|
|
|
|
# Buscar por key primero
|
|
results = self.manager.search(
|
|
category="bass",
|
|
key=key,
|
|
bpm=bpm,
|
|
bpm_tolerance=5,
|
|
limit=count * 10
|
|
)
|
|
|
|
# Si no hay suficientes, buscar sin key
|
|
if len(results) < count:
|
|
more = self.manager.search(
|
|
category="bass",
|
|
bpm=bpm,
|
|
bpm_tolerance=10,
|
|
limit=count * 10
|
|
)
|
|
results.extend(more)
|
|
|
|
# Buscar por género también
|
|
genre_results = self.manager.search(
|
|
category="bass",
|
|
genres=[genre],
|
|
limit=count * 8
|
|
)
|
|
results.extend(genre_results)
|
|
|
|
# Eliminar duplicados
|
|
seen = set()
|
|
unique = []
|
|
for s in results:
|
|
if s.id not in seen:
|
|
seen.add(s.id)
|
|
unique.append(s)
|
|
|
|
# Para bass, preferimos loops en la mayoría de casos
|
|
# excepto para bass one-shots (808, stabs)
|
|
prefer_oneshot = 'trap' in genre.lower() or 'hip-hop' in genre.lower()
|
|
|
|
return self._pick_multiple_ranked(
|
|
unique,
|
|
count=count,
|
|
target_key=key,
|
|
target_bpm=bpm,
|
|
target_genre=genre,
|
|
prefer_oneshot=prefer_oneshot,
|
|
pool_size=15,
|
|
context="bass"
|
|
)
|
|
|
|
def _select_synth_samples(self,
|
|
genre: str,
|
|
key: str,
|
|
bpm: float,
|
|
count: int = 3) -> List[Sample]:
|
|
"""
|
|
Selecciona samples de sintetizador compatibles con mejor ranking.
|
|
|
|
Mejoras Fase 4:
|
|
- Diversidad de tipos (lead, pad, pluck, chord)
|
|
- Balance loops preferentemente para texturas
|
|
- Penalización de familias repetidas
|
|
"""
|
|
if not self.manager:
|
|
return []
|
|
|
|
# Buscar diferentes tipos de synths
|
|
synth_types = ['lead', 'pad', 'pluck', 'chord']
|
|
results = []
|
|
|
|
for synth_type in synth_types:
|
|
type_results = self.manager.search(
|
|
sample_type=synth_type,
|
|
key=key,
|
|
bpm=bpm,
|
|
bpm_tolerance=5,
|
|
limit=12
|
|
)
|
|
results.extend(type_results)
|
|
|
|
# Completar con búsqueda general
|
|
if len(results) < count * 2:
|
|
more = self.manager.search(
|
|
category="synths",
|
|
key=key,
|
|
limit=count * 10
|
|
)
|
|
results.extend(more)
|
|
|
|
# Eliminar duplicados
|
|
seen = set()
|
|
unique = []
|
|
for s in results:
|
|
if s.id not in seen:
|
|
seen.add(s.id)
|
|
unique.append(s)
|
|
|
|
# Para synths, preferimos loops para pads y chords
|
|
# one-shots para leads y plucks
|
|
prefer_oneshot = False # Default a loops para texturas
|
|
|
|
return self._pick_multiple_ranked(
|
|
unique,
|
|
count=count,
|
|
target_key=key,
|
|
target_bpm=bpm,
|
|
target_genre=genre,
|
|
prefer_oneshot=prefer_oneshot,
|
|
pool_size=15,
|
|
context="synth"
|
|
)
|
|
|
|
def _select_fx_samples(self, genre: str, count: int = 2, target_bpm: Optional[float] = None) -> List[Sample]:
|
|
"""
|
|
Selecciona efectos apropiados con mejor ranking.
|
|
"""
|
|
if not self.manager:
|
|
return []
|
|
|
|
results = self.manager.search(
|
|
category="one_shots",
|
|
sample_type="fx",
|
|
genres=[genre],
|
|
limit=count * 8
|
|
)
|
|
|
|
# También buscar en category fx directamente
|
|
fx_results = self.manager.search(
|
|
category="fx",
|
|
limit=count * 6
|
|
)
|
|
results.extend(fx_results)
|
|
|
|
# Eliminar duplicados
|
|
seen = set()
|
|
unique = []
|
|
for s in results:
|
|
if s.id not in seen:
|
|
seen.add(s.id)
|
|
unique.append(s)
|
|
|
|
return self._pick_multiple_ranked(
|
|
unique,
|
|
count=count,
|
|
target_bpm=target_bpm,
|
|
target_genre=genre,
|
|
prefer_oneshot=True, # FX generalmente son one-shots
|
|
pool_size=10,
|
|
context="fx"
|
|
)
|
|
|
|
def find_compatible_samples(self,
|
|
reference_sample: Sample,
|
|
sample_type: str = "",
|
|
max_results: int = 10) -> List[Tuple[Sample, float]]:
|
|
"""
|
|
Encuentra samples compatibles con uno de referencia.
|
|
|
|
Calcula score de compatibilidad basado en:
|
|
- Key (armonía)
|
|
- BPM (tempo)
|
|
- Género
|
|
- Características de audio
|
|
"""
|
|
if not self.manager:
|
|
return []
|
|
|
|
# Buscar candidatos
|
|
candidates = self.manager.search(
|
|
sample_type=sample_type or reference_sample.sample_type,
|
|
limit=50
|
|
)
|
|
|
|
results = []
|
|
for candidate in candidates:
|
|
if candidate.id == reference_sample.id:
|
|
continue
|
|
|
|
score = self._calculate_compatibility(reference_sample, candidate)
|
|
if score > 0.5: # Umbral mínimo
|
|
results.append((candidate, score))
|
|
|
|
# Ordenar por score
|
|
results.sort(key=lambda x: x[1], reverse=True)
|
|
return results[:max_results]
|
|
|
|
def _calculate_compatibility(self, sample1: Sample, sample2: Sample) -> float:
|
|
"""Calcula un score de compatibilidad entre dos samples"""
|
|
score = 0.0
|
|
weights = 0.0
|
|
|
|
# Compatibilidad de key (peso: 0.4)
|
|
if sample1.key and sample2.key:
|
|
if MANAGER_AVAILABLE:
|
|
key_compat = calculate_key_compatibility(sample1.key, sample2.key)
|
|
else:
|
|
key_compat = 1.0 if sample1.key == sample2.key else 0.5
|
|
score += key_compat * 0.4
|
|
weights += 0.4
|
|
|
|
# Compatibilidad de BPM (peso: 0.3)
|
|
if sample1.bpm and sample2.bpm:
|
|
bpm_diff = abs(sample1.bpm - sample2.bpm)
|
|
if bpm_diff == 0:
|
|
bpm_compat = 1.0
|
|
elif bpm_diff <= 3:
|
|
bpm_compat = 0.9
|
|
elif bpm_diff <= 6:
|
|
bpm_compat = 0.7
|
|
elif bpm_diff <= 10:
|
|
bpm_compat = 0.5
|
|
else:
|
|
bpm_compat = max(0.0, 1.0 - (bpm_diff / 50))
|
|
score += bpm_compat * 0.3
|
|
weights += 0.3
|
|
|
|
# Compatibilidad de género (peso: 0.2)
|
|
if sample1.genres and sample2.genres:
|
|
common_genres = set(sample1.genres) & set(sample2.genres)
|
|
if common_genres:
|
|
genre_compat = len(common_genres) / max(len(sample1.genres), len(sample2.genres))
|
|
score += genre_compat * 0.2
|
|
weights += 0.2
|
|
|
|
# Compatibilidad de categoría (peso: 0.1)
|
|
if sample1.category == sample2.category:
|
|
score += 0.1
|
|
weights += 0.1
|
|
|
|
return score / weights if weights > 0 else 0.0
|
|
|
|
def get_midi_mapping_for_kit(self, kit: DrumKit) -> Dict[str, Any]:
|
|
"""
|
|
Genera un mapeo MIDI completo para un kit de batería.
|
|
|
|
Returns:
|
|
Dict con información de mapeo para Ableton
|
|
"""
|
|
mapping = {
|
|
'kit_name': kit.name,
|
|
'notes': {},
|
|
'drum_rack_slots': {},
|
|
}
|
|
|
|
midi_map = kit.get_midi_mapping()
|
|
|
|
for note, sample in midi_map.items():
|
|
note_name = self._midi_note_to_name(note)
|
|
mapping['notes'][note] = {
|
|
'name': note_name,
|
|
'sample': sample.name if sample else None,
|
|
'sample_path': sample.path if sample else None,
|
|
}
|
|
|
|
# Mapeo para Drum Rack (0-127 pads)
|
|
if note in range(36, 52): # Rango de drums común
|
|
drum_rack_slot = note - 36
|
|
mapping['drum_rack_slots'][drum_rack_slot] = {
|
|
'note': note,
|
|
'sample': sample.name if sample else None,
|
|
'sample_path': sample.path if sample else None,
|
|
}
|
|
|
|
return mapping
|
|
|
|
def _midi_note_to_name(self, note: int) -> str:
|
|
"""Convierte número de nota MIDI a nombre"""
|
|
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
|
octave = (note // 12) - 1
|
|
name = note_names[note % 12]
|
|
return f"{name}{octave}"
|
|
|
|
def suggest_key_change(self,
|
|
current_key: str,
|
|
direction: str = "fifth_up") -> str:
|
|
"""
|
|
Sugiere un cambio de key armónico.
|
|
|
|
Args:
|
|
current_key: Key actual
|
|
direction: 'fifth_up', 'fifth_down', 'relative', 'parallel'
|
|
|
|
Returns:
|
|
Nueva key sugerida
|
|
"""
|
|
# Círculo de quintas
|
|
circle_major = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
|
|
circle_minor = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm']
|
|
|
|
is_minor = current_key.endswith('m')
|
|
root = current_key.rstrip('m')
|
|
|
|
circle = circle_minor if is_minor else circle_major
|
|
|
|
try:
|
|
idx = circle.index(current_key)
|
|
except ValueError:
|
|
# Intentar encontrar equivalente
|
|
return current_key
|
|
|
|
if direction == "fifth_up":
|
|
new_idx = (idx + 1) % 12
|
|
return circle[new_idx]
|
|
elif direction == "fifth_down":
|
|
new_idx = (idx - 1) % 12
|
|
return circle[new_idx]
|
|
elif direction == "relative":
|
|
# Cambiar entre mayor/menor relativo
|
|
if is_minor:
|
|
# De menor a mayor relativo (3 semitonos arriba)
|
|
rel_idx = (idx + 3) % 12
|
|
return circle_major[rel_idx]
|
|
else:
|
|
# De mayor a menor relativo (3 semitonos abajo)
|
|
rel_idx = (idx - 3) % 12
|
|
return circle_minor[rel_idx]
|
|
elif direction == "parallel":
|
|
# Cambiar entre mayor/menor paralelo
|
|
if is_minor:
|
|
return root
|
|
else:
|
|
return root + 'm'
|
|
|
|
return current_key
|
|
|
|
def create_variation(self,
|
|
original_group: InstrumentGroup,
|
|
variation_type: str = "energy_up") -> InstrumentGroup:
|
|
"""
|
|
Crea una variación de un grupo de instrumentos.
|
|
|
|
Args:
|
|
original_group: Grupo original
|
|
variation_type: Tipo de variación
|
|
|
|
Returns:
|
|
Nuevo InstrumentGroup variado
|
|
"""
|
|
new_group = InstrumentGroup(
|
|
genre=original_group.genre,
|
|
key=original_group.key,
|
|
bpm=original_group.bpm
|
|
)
|
|
|
|
if variation_type == "energy_up":
|
|
# Buscar samples más intensos
|
|
new_group.drums = self._select_drum_kit(
|
|
original_group.genre,
|
|
variation="heavy",
|
|
target_key=original_group.key
|
|
)
|
|
# Mantener key, buscar bass más agresivo
|
|
new_group.bass = self._select_bass_samples(
|
|
original_group.genre,
|
|
original_group.key,
|
|
original_group.bpm,
|
|
count=3
|
|
)
|
|
|
|
elif variation_type == "breakdown":
|
|
# Reducir elementos, mantener key
|
|
new_group.drums = DrumKit(name="minimal")
|
|
new_group.drums.kick = original_group.drums.kick
|
|
new_group.drums.hat_closed = original_group.drums.hat_closed
|
|
# Solo pads y elementos atmosféricos
|
|
new_group.synths = self._select_synth_samples(
|
|
original_group.genre,
|
|
original_group.key,
|
|
original_group.bpm,
|
|
count=2
|
|
)
|
|
|
|
elif variation_type == "key_change":
|
|
# Cambiar de tonalidad
|
|
new_key = self.suggest_key_change(original_group.key, "fifth_up")
|
|
new_group.key = new_key
|
|
new_group.bass = self._select_bass_samples(
|
|
original_group.genre,
|
|
new_key,
|
|
original_group.bpm,
|
|
count=3
|
|
)
|
|
new_group.synths = self._select_synth_samples(
|
|
original_group.genre,
|
|
new_key,
|
|
original_group.bpm,
|
|
count=3
|
|
)
|
|
|
|
return new_group
|
|
|
|
|
|
# ============================================================================
|
|
# Funciones de conveniencia
|
|
# ============================================================================
|
|
|
|
_selector: Optional[SampleSelector] = None
|
|
|
|
|
|
def get_selector(session_seed: Optional[int] = None) -> SampleSelector:
|
|
"""Obtiene la instancia global del selector"""
|
|
global _selector
|
|
if _selector is None:
|
|
_selector = SampleSelector(session_seed=session_seed)
|
|
elif session_seed is not None:
|
|
_selector._session_seed = session_seed
|
|
_selector._selection_counter = 0
|
|
return _selector
|
|
|
|
|
|
def reset_selector():
|
|
"""Resetea el selector global para una nueva sesión"""
|
|
global _selector
|
|
_selector = None
|
|
|
|
|
|
def select_samples_for_track(genre: str,
|
|
key: Optional[str] = None,
|
|
bpm: Optional[float] = None,
|
|
session_seed: Optional[int] = None) -> Dict[str, Any]:
|
|
"""
|
|
Selecciona samples para un track completo.
|
|
|
|
Args:
|
|
genre: Género musical
|
|
key: Tonalidad (auto-selecciona si None)
|
|
bpm: BPM (auto-selecciona si None)
|
|
session_seed: Semilla para reproducibilidad
|
|
|
|
Returns:
|
|
Dict con toda la información de selección
|
|
"""
|
|
selector = get_selector(session_seed=session_seed)
|
|
group = selector.select_for_genre(genre, key, bpm)
|
|
|
|
return {
|
|
'genre': group.genre,
|
|
'key': group.key,
|
|
'bpm': group.bpm,
|
|
'drum_kit': group.drums.to_dict(),
|
|
'midi_mapping': selector.get_midi_mapping_for_kit(group.drums),
|
|
'bass_samples': [s.to_dict() for s in group.bass],
|
|
'synth_samples': [s.to_dict() for s in group.synths],
|
|
'fx_samples': [s.to_dict() for s in group.fx],
|
|
'session_seed': selector._session_seed,
|
|
}
|
|
|
|
|
|
def get_drum_kit(genre: str, variation: str = "standard", key: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Obtiene un kit de batería para un género.
|
|
|
|
Args:
|
|
genre: Género musical
|
|
variation: Variación del kit
|
|
key: Key para matching armónico
|
|
"""
|
|
selector = get_selector()
|
|
kit = selector._select_drum_kit(genre, variation, target_key=key)
|
|
|
|
return {
|
|
'kit': kit.to_dict(),
|
|
'midi_mapping': selector.get_midi_mapping_for_kit(kit),
|
|
}
|
|
|
|
|
|
def find_compatible(sample_path: str, max_results: int = 10) -> List[Dict[str, Any]]:
|
|
"""Encuentra samples compatibles con uno dado"""
|
|
selector = get_selector()
|
|
manager = get_manager()
|
|
|
|
sample = manager.get_by_path(sample_path)
|
|
if not sample:
|
|
return []
|
|
|
|
compatible = selector.find_compatible_samples(sample, max_results=max_results)
|
|
return [
|
|
{
|
|
'sample': s.to_dict(),
|
|
'compatibility_score': score
|
|
}
|
|
for s, score in compatible
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# Funciones para GPU/Embeddings (opcional)
|
|
# ============================================================================
|
|
|
|
def calculate_embedding_similarity(samples: List['Sample'],
|
|
reference: 'Sample',
|
|
use_gpu: bool = True) -> List[Tuple['Sample', float]]:
|
|
"""
|
|
Calcula similitud de embeddings entre samples usando operaciones vectorizadas.
|
|
Requiere que los samples tengan embeddings pre-calculados.
|
|
|
|
Args:
|
|
samples: Lista de samples a comparar
|
|
reference: Sample de referencia
|
|
use_gpu: Usar GPU si está disponible
|
|
|
|
Returns:
|
|
Lista de (sample, similarity_score) ordenada por similitud
|
|
"""
|
|
if not NUMPY_AVAILABLE:
|
|
logger.warning("NumPy no disponible, usando similitud básica")
|
|
return [(s, 0.5) for s in samples]
|
|
|
|
# Verificar si hay embeddings disponibles
|
|
ref_embedding = getattr(reference, 'embedding', None)
|
|
if ref_embedding is None:
|
|
logger.warning("No hay embedding de referencia, usando similitud básica")
|
|
return [(s, 0.5) for s in samples]
|
|
|
|
results = []
|
|
xp = cp if (use_gpu and GPU_AVAILABLE) else np
|
|
|
|
try:
|
|
ref_vec = xp.array(ref_embedding)
|
|
ref_norm = xp.linalg.norm(ref_vec)
|
|
|
|
for sample in samples:
|
|
sample_embedding = getattr(sample, 'embedding', None)
|
|
if sample_embedding is not None:
|
|
sample_vec = xp.array(sample_embedding)
|
|
sample_norm = xp.linalg.norm(sample_vec)
|
|
|
|
if ref_norm > 0 and sample_norm > 0:
|
|
similarity = float(xp.dot(ref_vec, sample_vec) / (ref_norm * sample_norm))
|
|
else:
|
|
similarity = 0.0
|
|
else:
|
|
similarity = 0.0
|
|
|
|
results.append((sample, similarity))
|
|
|
|
# Ordenar por similitud descendente
|
|
results.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error calculando similitud de embeddings: {e}")
|
|
return [(s, 0.5) for s in samples]
|
|
|
|
return results
|
|
|
|
|
|
def batch_score_samples(samples: List['Sample'],
|
|
target_key: Optional[str] = None,
|
|
target_bpm: Optional[float] = None,
|
|
target_genre: Optional[str] = None,
|
|
use_gpu: bool = True) -> List[Tuple['Sample', float]]:
|
|
"""
|
|
Calcula scores para múltiples samples de forma vectorizada.
|
|
Usa NumPy o CuPy para aceleración.
|
|
|
|
Args:
|
|
samples: Lista de samples a puntuar
|
|
target_key: Key objetivo
|
|
target_bpm: BPM objetivo
|
|
target_genre: Género objetivo
|
|
use_gpu: Usar GPU si está disponible
|
|
|
|
Returns:
|
|
Lista de (sample, score) ordenada por score descendente
|
|
"""
|
|
if not samples:
|
|
return []
|
|
|
|
if not NUMPY_AVAILABLE or len(samples) < 10:
|
|
# Para pocos samples, usar scoring individual
|
|
selector = get_selector()
|
|
results = []
|
|
for sample in samples:
|
|
score = selector._calculate_sample_score(
|
|
sample,
|
|
target_key=target_key,
|
|
target_bpm=target_bpm,
|
|
target_genre=target_genre
|
|
)
|
|
results.append((sample, score))
|
|
results.sort(key=lambda x: x[1], reverse=True)
|
|
return results
|
|
|
|
# Vectorized scoring con NumPy/CuPy
|
|
xp = cp if (use_gpu and GPU_AVAILABLE) else np
|
|
|
|
ratings = xp.array([min(1.0, (s.rating or 0) / 5.0) for s in samples])
|
|
|
|
# Key compatibility
|
|
key_scores = xp.zeros(len(samples))
|
|
if target_key:
|
|
for i, s in enumerate(samples):
|
|
if s.key:
|
|
if MANAGER_AVAILABLE:
|
|
key_scores[i] = calculate_key_compatibility(target_key, s.key)
|
|
else:
|
|
key_scores[i] = 1.0 if s.key == target_key else 0.5
|
|
else:
|
|
key_scores[i] = 0.5
|
|
|
|
# BPM compatibility
|
|
bpm_scores = xp.zeros(len(samples))
|
|
if target_bpm:
|
|
for i, s in enumerate(samples):
|
|
if s.bpm:
|
|
diff = abs(s.bpm - target_bpm)
|
|
if diff == 0:
|
|
bpm_scores[i] = 1.0
|
|
elif diff <= 3:
|
|
bpm_scores[i] = 0.95
|
|
elif diff <= 6:
|
|
bpm_scores[i] = 0.85
|
|
elif diff <= 10:
|
|
bpm_scores[i] = 0.70
|
|
else:
|
|
bpm_scores[i] = max(0.2, 1.0 - (diff / 30))
|
|
else:
|
|
bpm_scores[i] = 0.5
|
|
|
|
# Genre compatibility
|
|
genre_scores = xp.zeros(len(samples))
|
|
if target_genre:
|
|
genre_lower = target_genre.lower().replace(' ', '-')
|
|
for i, s in enumerate(samples):
|
|
if s.genres:
|
|
sample_genres = [g.lower().replace(' ', '-') for g in s.genres]
|
|
if genre_lower in sample_genres:
|
|
genre_scores[i] = 1.0
|
|
elif any(g in genre_lower or genre_lower in g for g in sample_genres):
|
|
genre_scores[i] = 0.7
|
|
else:
|
|
genre_scores[i] = 0.3
|
|
else:
|
|
genre_scores[i] = 0.5
|
|
|
|
# Combined score (weighted)
|
|
weights = xp.array([0.25, 0.25, 0.25, 0.25]) # rating, key, bpm, genre
|
|
scores_matrix = xp.stack([ratings, key_scores, bpm_scores, genre_scores])
|
|
final_scores = xp.dot(weights, scores_matrix)
|
|
|
|
# Convertir a lista y ordenar
|
|
results = [(samples[i], float(final_scores[i])) for i in range(len(samples))]
|
|
results.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
return results
|
|
|
|
|
|
# Testing
|
|
if __name__ == "__main__":
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
print("Sample Selector - Test (Fase 4 mejorada)")
|
|
print("=" * 60)
|
|
|
|
selector = SampleSelector()
|
|
|
|
# Test de selección por género
|
|
genres = ['techno', 'house', 'tech-house', 'deep-house']
|
|
|
|
for genre in genres:
|
|
print(f"\n{genre.upper()}:")
|
|
profile = selector._get_genre_profile(genre)
|
|
print(f" BPM: {profile.bpm_range}")
|
|
print(f" Keys: {profile.common_keys}")
|
|
print(f" Características: {', '.join(profile.characteristics)}")
|
|
|
|
# Test de selección completa con reproducibilidad
|
|
print("\n" + "=" * 60)
|
|
print("SELECCIÓN PARA TECHNO (session_seed=12345):")
|
|
|
|
# Usar semilla para reproducibilidad
|
|
selector_test = SampleSelector(session_seed=12345)
|
|
group = selector_test.select_for_genre('techno', key='F#m', bpm=130)
|
|
|
|
print(f"\nKey: {group.key}, BPM: {group.bpm}")
|
|
print(f"Session Seed: {selector_test._session_seed}")
|
|
print(f"\nDrum Kit: {group.drums.name}")
|
|
if group.drums.kick:
|
|
print(f" Kick: {group.drums.kick.name} (role validated)")
|
|
if group.drums.snare:
|
|
print(f" Snare: {group.drums.snare.name} (role validated)")
|
|
if group.drums.clap:
|
|
print(f" Clap: {group.drums.clap.name} (role validated)")
|
|
if group.drums.hat_closed:
|
|
print(f" Hat: {group.drums.hat_closed.name} (role validated)")
|
|
|
|
print(f"\nBass samples: {len(group.bass)}")
|
|
print(f"Synth samples: {len(group.synths)}")
|
|
|
|
# Test de reproducibilidad - segunda corrida con misma semilla
|
|
print("\n" + "=" * 60)
|
|
print("TEST DE REPRODUCIBILIDAD (misma semilla):")
|
|
|
|
selector_test2 = SampleSelector(session_seed=12345)
|
|
group2 = selector_test2.select_for_genre('techno', key='F#m', bpm=130)
|
|
|
|
print(f"Misma key: {group.key == group2.key}")
|
|
print(f"Mismo BPM: {group.bpm == group2.bpm}")
|
|
|
|
# Test de validación de roles
|
|
print("\n" + "=" * 60)
|
|
print("TEST DE VALIDACIÓN DE ROLES:")
|
|
|
|
# Crear un sample mock para testing
|
|
class MockSample:
|
|
def __init__(self, name, sample_type, category):
|
|
self.name = name
|
|
self.sample_type = sample_type
|
|
self.category = category
|
|
self.subcategory = ""
|
|
self.id = name
|
|
self.key = None
|
|
self.bpm = None
|
|
self.rating = 3
|
|
self.genres = []
|
|
self.rms_energy = 0.5
|
|
self.duration = 0.5
|
|
|
|
# Test samples correctos
|
|
kick_sample = MockSample("Techno_Kick_01", "kick", "drums")
|
|
snare_sample = MockSample("Techno_Snare_02", "snare", "drums")
|
|
clap_sample = MockSample("Techno_Clap_03", "clap", "drums")
|
|
|
|
print(f" Kick para rol 'kick': {selector._validate_sample_for_role(kick_sample, 'kick'):.2f}")
|
|
print(f" Snare para rol 'snare': {selector._validate_sample_for_role(snare_sample, 'snare'):.2f}")
|
|
print(f" Clap para rol 'clap': {selector._validate_sample_for_role(clap_sample, 'clap'):.2f}")
|
|
|
|
# Test samples incorrectos (ABSURDOS)
|
|
print(f" Snare para rol 'kick': {selector._validate_sample_for_role(snare_sample, 'kick'):.2f} (debería ser bajo)")
|
|
print(f" Clap para rol 'hat_closed': {selector._validate_sample_for_role(clap_sample, 'hat_closed'):.2f} (debería ser bajo)")
|
|
|
|
print("\n" + "=" * 60)
|
|
print(f"NumPy disponible: {NUMPY_AVAILABLE}")
|
|
print(f"GPU disponible: {GPU_AVAILABLE}")
|