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>
This commit is contained in:
398
AbletonMCP_AI/MCP_Server/audio_key_compatibility.py
Normal file
398
AbletonMCP_AI/MCP_Server/audio_key_compatibility.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user