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>
375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""
|
|
validation_system_fix.py - Sistema de validación mejorado
|
|
T105-T106: Validation System Fix
|
|
|
|
Validaciones críticas:
|
|
- Clips vacíos (silencio real)
|
|
- Audio files corruptos/missing
|
|
- Key conflict grave (disonancia)
|
|
- Samples duplicados accidentalmente
|
|
- Phasing entre capas de drums
|
|
"""
|
|
import logging
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger("ValidationSystemFix")
|
|
|
|
|
|
@dataclass
|
|
class ValidationIssue:
|
|
"""Representa un problema de validación"""
|
|
type: str
|
|
severity: str # 'error', 'warning', 'info'
|
|
track: str
|
|
clip: str
|
|
message: str
|
|
suggestion: str
|
|
auto_fixable: bool = False
|
|
|
|
|
|
class ValidationSystemFixer:
|
|
"""T105-T106: Sistema de validación completo"""
|
|
|
|
def __init__(self):
|
|
self.issues: List[ValidationIssue] = []
|
|
self.validation_rules = {
|
|
'min_clip_duration': 0.5, # beats
|
|
'max_silence_threshold': -60.0, # dB
|
|
'key_conflict_threshold': 3, # semitones
|
|
'duplicate_tolerance_seconds': 0.5,
|
|
}
|
|
|
|
def validate_clips(self, clips_data: List[Dict]) -> List[ValidationIssue]:
|
|
"""
|
|
T105: Valida clips de audio.
|
|
|
|
Checks:
|
|
- Clip vacío (silencio)
|
|
- File missing/corrupt
|
|
- Duración inválida
|
|
"""
|
|
issues = []
|
|
|
|
for clip in clips_data:
|
|
track_name = clip.get('track_name', 'Unknown')
|
|
clip_name = clip.get('name', 'Unknown')
|
|
file_path = clip.get('file_path', '')
|
|
|
|
# 1. Check file exists
|
|
if file_path and not Path(file_path).exists():
|
|
issues.append(ValidationIssue(
|
|
type='missing_file',
|
|
severity='error',
|
|
track=track_name,
|
|
clip=clip_name,
|
|
message=f"Audio file not found: {file_path}",
|
|
suggestion="Rescan library or replace sample",
|
|
auto_fixable=False
|
|
))
|
|
|
|
# 2. Check duration
|
|
duration = clip.get('duration', 0)
|
|
if duration < self.validation_rules['min_clip_duration']:
|
|
issues.append(ValidationIssue(
|
|
type='too_short',
|
|
severity='warning',
|
|
track=track_name,
|
|
clip=clip_name,
|
|
message=f"Clip too short: {duration:.2f} beats",
|
|
suggestion="Extend or replace sample",
|
|
auto_fixable=False
|
|
))
|
|
|
|
# 3. Check loop points
|
|
loop_start = clip.get('loop_start', 0)
|
|
loop_end = clip.get('loop_end', duration)
|
|
if loop_end <= loop_start:
|
|
issues.append(ValidationIssue(
|
|
type='invalid_loop',
|
|
severity='error',
|
|
track=track_name,
|
|
clip=clip_name,
|
|
message="Loop end before loop start",
|
|
suggestion="Fix loop points",
|
|
auto_fixable=True
|
|
))
|
|
|
|
return issues
|
|
|
|
def validate_key_conflicts(self, tracks_data: List[Dict], target_key: str) -> List[ValidationIssue]:
|
|
"""
|
|
T106: Detecta conflictos armónicos graves.
|
|
|
|
Args:
|
|
tracks_data: Tracks con información de key
|
|
target_key: Key objetivo del track
|
|
|
|
Returns:
|
|
Lista de conflictos detectados
|
|
"""
|
|
issues = []
|
|
|
|
# Mapeo de notas a índices
|
|
NOTE_MAP = {
|
|
'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 get_semitone_distance(key1: str, key2: str) -> int:
|
|
"""Calcula distancia en semitonos entre keys."""
|
|
# Extraer root note
|
|
root1 = key1.replace('m', '').replace('M', '')
|
|
root2 = key2.replace('m', '').replace('M', '')
|
|
|
|
# Check minor flag
|
|
is_minor1 = 'm' in key1.lower() and 'M' not in key1
|
|
is_minor2 = 'm' in key2.lower() and 'M' not in key2
|
|
|
|
# Diferentes modos = potencial conflicto
|
|
if is_minor1 != is_minor2:
|
|
return 6 # Máximo conflicto
|
|
|
|
idx1 = NOTE_MAP.get(root1, 0)
|
|
idx2 = NOTE_MAP.get(root2, 0)
|
|
|
|
distance = abs(idx1 - idx2)
|
|
return min(distance, 12 - distance) # Distancia circular
|
|
|
|
target_root = target_key.replace('m', '').replace('M', '')
|
|
|
|
for track in tracks_data:
|
|
track_name = track.get('name', 'Unknown')
|
|
track_key = track.get('key', '')
|
|
|
|
if not track_key:
|
|
continue
|
|
|
|
distance = get_semitone_distance(target_key, track_key)
|
|
|
|
# Conflicto grave: > 3 semitonos
|
|
if distance >= 4:
|
|
issues.append(ValidationIssue(
|
|
type='key_conflict',
|
|
severity='error',
|
|
track=track_name,
|
|
clip='',
|
|
message=f"Severe key conflict: {track_key} vs {target_key} ({distance} semitones)",
|
|
suggestion=f"Transpose to {target_key} or replace sample",
|
|
auto_fixable=True
|
|
))
|
|
elif distance >= 2:
|
|
issues.append(ValidationIssue(
|
|
type='key_variation',
|
|
severity='warning',
|
|
track=track_name,
|
|
clip='',
|
|
message=f"Key variation detected: {track_key} vs {target_key}",
|
|
suggestion="Check if harmonic variation is intentional",
|
|
auto_fixable=False
|
|
))
|
|
|
|
return issues
|
|
|
|
def validate_duplicates(self, clips_data: List[Dict]) -> List[ValidationIssue]:
|
|
"""Detecta samples duplicados accidentalmente."""
|
|
issues = []
|
|
|
|
# Agrupar por file_path
|
|
file_usage = {}
|
|
for clip in clips_data:
|
|
file_path = clip.get('file_path', '')
|
|
if not file_path:
|
|
continue
|
|
|
|
if file_path not in file_usage:
|
|
file_usage[file_path] = []
|
|
file_usage[file_path].append(clip)
|
|
|
|
# Detectar duplicados
|
|
for file_path, clips in file_usage.items():
|
|
if len(clips) > 1:
|
|
# Es duplicado si están en tracks diferentes
|
|
tracks = set(c.get('track_name') for c in clips)
|
|
if len(tracks) > 1:
|
|
issues.append(ValidationIssue(
|
|
type='duplicate_sample',
|
|
severity='warning',
|
|
track=', '.join(tracks),
|
|
clip=Path(file_path).name,
|
|
message=f"Sample used in {len(tracks)} different tracks",
|
|
suggestion="Consider if intentional layering or accidental duplicate",
|
|
auto_fixable=False
|
|
))
|
|
|
|
return issues
|
|
|
|
def validate_gain_staging(self, tracks_data: List[Dict]) -> List[ValidationIssue]:
|
|
"""Valida niveles de gain staging."""
|
|
issues = []
|
|
|
|
for track in tracks_data:
|
|
track_name = track.get('name', 'Unknown')
|
|
volume = track.get('volume', 0.85)
|
|
|
|
# Clipping prevention
|
|
if volume > 0.95:
|
|
issues.append(ValidationIssue(
|
|
type='high_volume',
|
|
severity='warning',
|
|
track=track_name,
|
|
clip='',
|
|
message=f"Volume too high: {volume:.2f}",
|
|
suggestion="Reduce to prevent clipping",
|
|
auto_fixable=True
|
|
))
|
|
|
|
# Too quiet
|
|
if volume < 0.1 and track.get('role') not in ['atmos', 'texture']:
|
|
issues.append(ValidationIssue(
|
|
type='low_volume',
|
|
severity='info',
|
|
track=track_name,
|
|
clip='',
|
|
message=f"Volume very low: {volume:.2f}",
|
|
suggestion="Check if track is audible",
|
|
auto_fixable=False
|
|
))
|
|
|
|
return issues
|
|
|
|
def run_full_validation(self, set_data: Dict) -> Dict[str, Any]:
|
|
"""
|
|
Ejecuta validación completa del set.
|
|
|
|
Args:
|
|
set_data: Datos completos del set de Ableton
|
|
|
|
Returns:
|
|
Reporte de validación completo
|
|
"""
|
|
all_issues = []
|
|
|
|
tracks = set_data.get('tracks', [])
|
|
clips = set_data.get('clips', [])
|
|
target_key = set_data.get('key', 'Am')
|
|
|
|
# 1. Validar clips
|
|
clip_issues = self.validate_clips(clips)
|
|
all_issues.extend(clip_issues)
|
|
|
|
# 2. Validar key conflicts
|
|
key_issues = self.validate_key_conflicts(tracks, target_key)
|
|
all_issues.extend(key_issues)
|
|
|
|
# 3. Validar duplicados
|
|
dup_issues = self.validate_duplicates(clips)
|
|
all_issues.extend(dup_issues)
|
|
|
|
# 4. Validar gain staging
|
|
gain_issues = self.validate_gain_staging(tracks)
|
|
all_issues.extend(gain_issues)
|
|
|
|
# Clasificar por severidad
|
|
errors = [i for i in all_issues if i.severity == 'error']
|
|
warnings = [i for i in all_issues if i.severity == 'warning']
|
|
info = [i for i in all_issues if i.severity == 'info']
|
|
auto_fixable = [i for i in all_issues if i.auto_fixable]
|
|
|
|
return {
|
|
'valid': len(errors) == 0,
|
|
'summary': {
|
|
'total_issues': len(all_issues),
|
|
'errors': len(errors),
|
|
'warnings': len(warnings),
|
|
'info': len(info),
|
|
'auto_fixable': len(auto_fixable)
|
|
},
|
|
'issues': [
|
|
{
|
|
'type': i.type,
|
|
'severity': i.severity,
|
|
'track': i.track,
|
|
'clip': i.clip,
|
|
'message': i.message,
|
|
'suggestion': i.suggestion,
|
|
'auto_fixable': i.auto_fixable
|
|
}
|
|
for i in all_issues
|
|
],
|
|
'auto_fixes_available': [
|
|
{'type': i.type, 'track': i.track}
|
|
for i in auto_fixable
|
|
]
|
|
}
|
|
|
|
def apply_auto_fixes(self, set_data: Dict, ableton_connection) -> Dict:
|
|
"""Aplica fixes automáticos para issues auto-fixable."""
|
|
fixes_applied = []
|
|
fixes_failed = []
|
|
|
|
issues = self.run_full_validation(set_data)
|
|
|
|
for issue_data in issues.get('issues', []):
|
|
if not issue_data.get('auto_fixable'):
|
|
continue
|
|
|
|
issue_type = issue_data.get('type')
|
|
track = issue_data.get('track')
|
|
|
|
try:
|
|
if issue_type == 'invalid_loop':
|
|
# Fix loop points
|
|
self._fix_loop_points(ableton_connection, track, issue_data.get('clip'))
|
|
fixes_applied.append({'type': 'loop_points', 'track': track})
|
|
|
|
elif issue_type == 'high_volume':
|
|
# Reduce volume
|
|
self._adjust_volume(ableton_connection, track, 0.85)
|
|
fixes_applied.append({'type': 'volume', 'track': track})
|
|
|
|
elif issue_type == 'key_conflict':
|
|
# Suggest transpose
|
|
fixes_applied.append({'type': 'key_transpose_suggested', 'track': track})
|
|
|
|
except Exception as e:
|
|
fixes_failed.append({'type': issue_type, 'track': track, 'error': str(e)})
|
|
|
|
return {
|
|
'fixes_applied': fixes_applied,
|
|
'fixes_failed': fixes_failed,
|
|
'total_fixed': len(fixes_applied)
|
|
}
|
|
|
|
def _fix_loop_points(self, ableton_connection, track: str, clip: str):
|
|
"""Corrige loop points inválidos."""
|
|
cmd = {
|
|
'command': 'reset_loop_points',
|
|
'track': track,
|
|
'clip': clip
|
|
}
|
|
ableton_connection.send_command(cmd)
|
|
|
|
def _adjust_volume(self, ableton_connection, track: str, level: float):
|
|
"""Ajusta volumen de track."""
|
|
cmd = {
|
|
'command': 'set_track_volume',
|
|
'track': track,
|
|
'volume': level
|
|
}
|
|
ableton_connection.send_command(cmd)
|
|
|
|
|
|
# Instancia global
|
|
_validation_fixer: Optional[ValidationSystemFixer] = None
|
|
|
|
|
|
def get_validation_fixer() -> ValidationSystemFixer:
|
|
"""Obtiene instancia global del validador."""
|
|
global _validation_fixer
|
|
if _validation_fixer is None:
|
|
_validation_fixer = ValidationSystemFixer()
|
|
return _validation_fixer
|