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>
399 lines
13 KiB
Python
399 lines
13 KiB
Python
"""
|
|
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
|