""" audio_key_compatibility.py - Key Compatibility Matrix y Tonal Analysis FASE 4: T051-T062 """ import logging from typing import Dict, List, Tuple, Optional from dataclasses import dataclass logger = logging.getLogger("KeyCompatibility") @dataclass class KeyCompatibility: """Representa compatibilidad entre dos keys.""" key1: str key2: str semitone_distance: int compatibility_score: float # 0.0 - 1.0 relationship: str # 'same', 'fifth', 'relative', 'parallel', 'distant' class KeyCompatibilityMatrix: """ T052: Matriz completa de compatibilidad de keys musicales. Implementa relaciones armónicas basadas en: - Distancia de quintas (Circle of Fifths) - Relativos mayor/menor - Paralelos mayor/menor - Distancia en semitonos """ # Circle of Fifths: orden de keys por quintas CIRCLE_OF_FIFTHS_MAJOR = [ 'C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', # Sharps side 'Ab', 'Eb', 'Bb', 'F' # Flats side ] CIRCLE_OF_FIFTHS_MINOR = [ 'Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'Ebm', 'Bbm', # Sharps side 'Fm', 'Cm', 'Gm', 'Dm' # Flats side ] # Relativos mayor/menor RELATIVE_KEYS = { 'C': 'Am', 'G': 'Em', 'D': 'Bm', 'A': 'F#m', 'E': 'C#m', 'B': 'G#m', 'F#': 'Ebm', 'C#': 'Bbm', 'Ab': 'Fm', 'Eb': 'Cm', 'Bb': 'Gm', 'F': 'Dm', 'Am': 'C', 'Em': 'G', 'Bm': 'D', 'F#m': 'A', 'C#m': 'E', 'G#m': 'B', 'Ebm': 'F#', 'Bbm': 'C#', 'Fm': 'Ab', 'Cm': 'Eb', 'Gm': 'Bb', 'Dm': 'F' } # Paralelos mayor/menor (misma tonic, diferente modo) PARALLEL_KEYS = { 'C': 'Cm', 'G': 'Gm', 'D': 'Dm', 'A': 'Am', 'E': 'Em', 'B': 'Bm', 'F#': 'F#m', 'C#': 'C#m', 'Ab': 'Abm', 'Eb': 'Ebm', 'Bb': 'Bbm', 'F': 'Fm' } # Notas a índices cromáticos NOTE_INDEX = { 'C': 0, 'C#': 1, 'Db': 1, 'D': 2, 'D#': 3, 'Eb': 3, 'E': 4, 'F': 5, 'F#': 6, 'Gb': 6, 'G': 7, 'G#': 8, 'Ab': 8, 'A': 9, 'A#': 10, 'Bb': 10, 'B': 11 } def __init__(self): self._matrix: Dict[Tuple[str, str], float] = {} self._build_matrix() def _build_matrix(self): """Construye la matriz completa de compatibilidad.""" all_keys = self.CIRCLE_OF_FIFTHS_MAJOR + self.CIRCLE_OF_FIFTHS_MINOR for key1 in all_keys: for key2 in all_keys: if key1 == key2: score = 1.0 else: score = self._calculate_compatibility(key1, key2) self._matrix[(key1, key2)] = score def _calculate_compatibility(self, key1: str, key2: str) -> float: """ Calcula score de compatibilidad entre dos keys. Scores basados en teoría musical: - Misma key: 1.0 - Quinta directa: 0.95 - Relativo mayor/menor: 0.90 - Paralelo mayor/menor: 0.85 - 2 quintas de distancia: 0.80 - 3 quintas de distancia: 0.70 - 4+ quintas: 0.50 - Tritono (6 semitonos): 0.30 - Más lejos: 0.10-0.20 """ # Check same key if key1 == key2: return 1.0 # Check relativo if self.RELATIVE_KEYS.get(key1) == key2: return 0.90 # Check paralelo if self.PARALLEL_KEYS.get(key1) == key2: return 0.85 # Check quintas en circle of fifths distance_fifths = self._circle_distance(key1, key2) if distance_fifths == 1: return 0.95 elif distance_fifths == 2: return 0.80 elif distance_fifths == 3: return 0.70 elif distance_fifths >= 4: return max(0.20, 0.70 - (distance_fifths - 3) * 0.10) # Semitone distance fallback semitone_dist = self._semitone_distance(key1, key2) if semitone_dist == 6: # Tritono return 0.30 elif semitone_dist <= 2: return 0.75 elif semitone_dist <= 4: return 0.60 else: return 0.40 def _circle_distance(self, key1: str, key2: str) -> int: """Calcula distancia en circle of fifths.""" # Normalizar a mayores k1_major = self._to_major(key1) k2_major = self._to_major(key2) if k1_major not in self.CIRCLE_OF_FIFTHS_MAJOR or k2_major not in self.CIRCLE_OF_FIFTHS_MAJOR: return 99 idx1 = self.CIRCLE_OF_FIFTHS_MAJOR.index(k1_major) idx2 = self.CIRCLE_OF_FIFTHS_MAJOR.index(k2_major) # Distancia circular dist = abs(idx1 - idx2) return min(dist, 12 - dist) def _to_major(self, key: str) -> str: """Convierte cualquier key a su equivalente mayor.""" if key.endswith('m') and not key.endswith('M'): # Es menor, devolver relativo mayor return self.RELATIVE_KEYS.get(key, key[:-1]) return key def _semitone_distance(self, key1: str, key2: str) -> int: """Calcula distancia en semitonos entre roots de keys.""" # Extraer root note root1 = self._extract_root(key1) root2 = self._extract_root(key2) idx1 = self.NOTE_INDEX.get(root1, 0) idx2 = self.NOTE_INDEX.get(root2, 0) dist = abs(idx1 - idx2) return min(dist, 12 - dist) def _extract_root(self, key: str) -> str: """Extrae la nota root de una key (ej: 'C#m' -> 'C#').""" if len(key) >= 2 and key[1] in '#b': return key[:2] return key[0] def get_compatibility(self, key1: str, key2: str) -> float: """Obtiene score de compatibilidad entre dos keys.""" return self._matrix.get((key1, key2), 0.0) def get_related_keys(self, key: str, min_score: float = 0.80) -> List[Tuple[str, float]]: """Retorna keys relacionadas con score >= min_score.""" related = [] all_keys = self.CIRCLE_OF_FIFTHS_MAJOR + self.CIRCLE_OF_FIFTHS_MINOR for other_key in all_keys: if other_key == key: continue score = self.get_compatibility(key, other_key) if score >= min_score: related.append((other_key, score)) return sorted(related, key=lambda x: x[1], reverse=True) def get_compatibility_report(self, key1: str, key2: str) -> Dict: """ Genera reporte completo de compatibilidad entre dos keys. Returns dict con: - compatibility_score: float 0-1 - semitone_distance: int - relationship: str ('same', 'relative', 'parallel', 'fifth', 'distant') - compatible: bool """ score = self.get_compatibility(key1, key2) semitone_dist = self._semitone_distance(key1, key2) fifth_dist = self._circle_distance(key1, key2) # Determinar relación if key1 == key2: relationship = "same" elif self.RELATIVE_KEYS.get(key1) == key2: relationship = "relative" elif self.PARALLEL_KEYS.get(key1) == key2: relationship = "parallel" elif fifth_dist == 1: relationship = "fifth" elif fifth_dist <= 2: relationship = "close_fifth" else: relationship = "distant" return { 'key1': key1, 'key2': key2, 'compatibility_score': score, 'semitone_distance': semitone_dist, 'fifth_distance': fifth_dist, 'relationship': relationship, 'compatible': score >= 0.70 } def suggest_key_change(self, current_key: str, direction: str = "fifth_up") -> Optional[str]: """ T054: Sugiere cambio de key armónico. Args: current_key: Key actual direction: 'fifth_up', 'fifth_down', 'relative', 'parallel' Returns: Key sugerida o None """ if direction == "fifth_up": # Subir quinta = más energía return self._shift_fifth(current_key, 1) elif direction == "fifth_down": # Bajar quinta = más suave return self._shift_fifth(current_key, -1) elif direction == "relative": # Cambio a relativo mayor/menor return self.RELATIVE_KEYS.get(current_key) elif direction == "parallel": # Cambio a paralelo return self.PARALLEL_KEYS.get(current_key) return None def _shift_fifth(self, key: str, steps: int) -> Optional[str]: """Desplaza key por N quintas.""" major = self._to_major(key) if major not in self.CIRCLE_OF_FIFTHS_MAJOR: return None idx = self.CIRCLE_OF_FIFTHS_MAJOR.index(major) new_idx = (idx + steps) % 12 new_major = self.CIRCLE_OF_FIFTHS_MAJOR[new_idx] # Preservar modo (mayor/menor) if key.endswith('m') and not key.endswith('M'): return self.RELATIVE_KEYS.get(new_major, new_major.lower()) return new_major def validate_key_match(self, sample_key: str, project_key: str, tolerance: float = 0.70) -> bool: """ T055: Valida si un sample es compatible con el proyecto. Args: sample_key: Key del sample project_key: Key del proyecto tolerance: Score mínimo de compatibilidad (default 0.70) Returns: True si es compatible """ if not sample_key or not project_key: return True # Sin info de key, asumir compatible score = self.get_compatibility(sample_key, project_key) return score >= tolerance class TonalAnalyzer: """ T060-T062: Análisis tonal y espectral. """ # Rangos de brillo óptimos por rol (T056) BRIGHTNESS_RANGES = { 'sub_bass': (0, 100), # Muy oscuro 'bass': (100, 500), # Oscuro 'kick': (200, 1000), # Low-mid 'pad': (500, 3000), # Mid 'chords': (800, 4000), # Mid-high 'lead': (1000, 6000), # High 'pluck': (1500, 5000), # High-mid 'atmos': (300, 8000), # Variable 'fx': (500, 10000), # Variable } # Tags de color espectral (T061) SPECTRAL_TAGS = { 'dark': (0, 500), 'warm': (500, 1500), 'neutral': (1500, 3000), 'bright': (3000, 6000), 'harsh': (6000, 20000) } def __init__(self): self.key_matrix = KeyCompatibilityMatrix() def analyze_spectral_fit(self, spectral_centroid: float, role: str) -> float: """ T057: Calcula qué tan bien el brillo espectral se ajusta al rol. Args: spectral_centroid: Hz role: Rol del sample Returns: Score 0.0-1.0 de ajuste espectral """ range_vals = self.BRIGHTNESS_RANGES.get(role, (0, 10000)) min_val, max_val = range_vals if min_val <= spectral_centroid <= max_val: return 1.0 # Fuera de rango: calcular penalización if spectral_centroid < min_val: diff = min_val - spectral_centroid else: diff = spectral_centroid - max_val # Penalización proporcional penalty = min(1.0, diff / 2000.0) return max(0.0, 1.0 - penalty) def tag_spectral_color(self, spectral_centroid: float) -> str: """ T061: Asigna tag de color espectral. Returns: 'dark', 'warm', 'neutral', 'bright', 'harsh' """ for tag, (min_hz, max_hz) in self.SPECTRAL_TAGS.items(): if min_hz <= spectral_centroid <= max_hz: return tag return 'unknown' def get_key_compatibility_report(self, key1: str, key2: str) -> Dict: """Genera reporte completo de compatibilidad.""" score = self.key_matrix.get_compatibility(key1, key2) related = self.key_matrix.get_related_keys(key1, min_score=0.70) return { 'key1': key1, 'key2': key2, 'compatibility_score': round(score, 2), 'compatible': score >= 0.70, 'related_keys': related[:5], 'suggested_changes': { 'fifth_up': self.key_matrix.suggest_key_change(key1, 'fifth_up'), 'fifth_down': self.key_matrix.suggest_key_change(key1, 'fifth_down'), 'relative': self.key_matrix.suggest_key_change(key1, 'relative'), 'parallel': self.key_matrix.suggest_key_change(key1, 'parallel') } } # Instancia global _key_matrix: Optional[KeyCompatibilityMatrix] = None _tonal_analyzer: Optional[TonalAnalyzer] = None def get_key_matrix() -> KeyCompatibilityMatrix: """Obtiene instancia global de la matriz de compatibilidad.""" global _key_matrix if _key_matrix is None: _key_matrix = KeyCompatibilityMatrix() return _key_matrix def get_tonal_analyzer() -> TonalAnalyzer: """Obtiene instancia global del analizador tonal.""" global _tonal_analyzer if _tonal_analyzer is None: _tonal_analyzer = TonalAnalyzer() return _tonal_analyzer