Files
ableton-mcp-ai/AbletonMCP_AI/MCP_Server/audio_key_compatibility.py
renato97 4332ff65da Implement FASE 3, 4, 6 - 15 new MCP tools, 76/110 tasks complete
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>
2026-03-29 00:59:24 -03:00

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