Files
AbletonMCP_AI/AbletonMCP_AI/mcp_server/engines/harmony_engine.py
2026-04-12 22:14:35 -03:00

2115 lines
80 KiB
Python

"""
Harmony Engine - Motor de Inteligencia Musical Avanzada para AbletonMCP_AI.
Este módulo proporciona análisis musical sofisticado, generación de armonías,
variación inteligente de loops, manipulación avanzada de samples, y
comparación con referencias profesionales.
Clases principales:
- ProjectAnalyzer: Análisis de key, energía y balance de secciones
- CounterMelodyGenerator: Generación de contra-melodías y armonías
- VariationEngine: Variación inteligente de loops y secciones
- SampleIntelligence: Manipulación avanzada de samples
- ReferenceMatcher: Comparación y adaptación a referencias
Tareas implementadas:
- Parte 1 (T041-T045): Análisis y Adaptación
- Parte 2 (T046-T050): Variación Inteligente
- Parte 3 (T051-T055): Samples Inteligentes
- Parte 4 (T056-T060): Referencia y Comparación
"""
import json
import logging
import os
import random
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
logger = logging.getLogger("HarmonyEngine")
# =============================================================================
# DATACLASSES - Perfiles y Métricas Musicales
# =============================================================================
@dataclass
class EnergyCurve:
"""Perfil de energía a lo largo de una canción o sección.
Atributos:
bars: Posiciones en compases donde se midió la energía
levels: Niveles de energía (0.0-1.0) en cada posición
section_names: Nombres de las secciones correspondientes
"""
bars: List[int] = field(default_factory=list)
levels: List[float] = field(default_factory=list)
section_names: List[str] = field(default_factory=list)
def get_level_at(self, bar: int) -> float:
"""Obtiene nivel de energía en un compás específico."""
if not self.bars:
return 0.5
closest_idx = min(range(len(self.bars)), key=lambda i: abs(self.bars[i] - bar))
return self.levels[closest_idx] if closest_idx < len(self.levels) else 0.5
def get_average(self, start_bar: int, end_bar: int) -> float:
"""Calcula energía promedio entre dos compases."""
relevant = [l for b, l in zip(self.bars, self.levels) if start_bar <= b <= end_bar]
return np.mean(relevant) if relevant else 0.5
def get_peak_level(self) -> float:
"""Retorna el nivel de energía máximo."""
return max(self.levels) if self.levels else 0.0
def get_trough_level(self) -> float:
"""Retorna el nivel de energía mínimo."""
return min(self.levels) if self.levels else 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"bars": self.bars,
"levels": self.levels,
"section_names": self.section_names,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "EnergyCurve":
return cls(
bars=data.get("bars", []),
levels=data.get("levels", []),
section_names=data.get("section_names", []),
)
@dataclass
class SpectrumProfile:
"""Perfil espectral con frecuencias y magnitudes por banda.
Atributos:
frequencies: Lista de frecuencias en Hz
magnitudes: Lista de magnitudes en dB
low_energy: Energía en frecuencias bajas (20-250 Hz)
low_mid_energy: Energía en low-mid (250-500 Hz)
mid_energy: Energía en frecuencias medias (500-2000 Hz)
high_mid_energy: Energía en high-mid (2000-4000 Hz)
high_energy: Energía en frecuencias altas (4000-20000 Hz)
"""
frequencies: List[float] = field(default_factory=list)
magnitudes: List[float] = field(default_factory=list)
low_energy: float = 0.0
low_mid_energy: float = 0.0
mid_energy: float = 0.0
high_mid_energy: float = 0.0
high_energy: float = 0.0
def get_balance_score(self) -> float:
"""Retorna score de balance espectral (0.0-1.0)."""
energies = [self.low_energy, self.low_mid_energy, self.mid_energy,
self.high_mid_energy, self.high_energy]
if not any(energies):
return 0.5
ideal = [0.25, 0.15, 0.25, 0.20, 0.15]
normalized = [e/sum(energies) for e in energies]
deviation = sum(abs(n - i) for n, i in zip(normalized, ideal))
return max(0.0, 1.0 - deviation)
def get_dominant_frequency_range(self) -> str:
"""Determina el rango de frecuencia dominante."""
energies = {
"low": self.low_energy,
"low_mid": self.low_mid_energy,
"mid": self.mid_energy,
"high_mid": self.high_mid_energy,
"high": self.high_energy,
}
return max(energies.items(), key=lambda x: x[1])[0]
def to_dict(self) -> Dict[str, Any]:
return {
"frequencies": self.frequencies,
"magnitudes": self.magnitudes,
"low_energy": self.low_energy,
"low_mid_energy": self.low_mid_energy,
"mid_energy": self.mid_energy,
"high_mid_energy": self.high_mid_energy,
"high_energy": self.high_energy,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpectrumProfile":
return cls(
frequencies=data.get("frequencies", []),
magnitudes=data.get("magnitudes", []),
low_energy=data.get("low_energy", 0.0),
low_mid_energy=data.get("low_mid_energy", 0.0),
mid_energy=data.get("mid_energy", 0.0),
high_mid_energy=data.get("high_mid_energy", 0.0),
high_energy=data.get("high_energy", 0.0),
)
@dataclass
class StereoWidth:
"""Ancho estéreo por bandas de frecuencia.
Atributos:
low: Ancho en frecuencias bajas 20-250 Hz (ideal: mono)
mid_low: Ancho en rango 250-500 Hz
mid: Ancho en rango 500-2000 Hz
high: Ancho en frecuencias altas 2000+ Hz (ideal: ancho)
overall_width: Ancho estéreo general promedio
"""
low: float = 0.0
mid_low: float = 0.0
mid: float = 0.0
high: float = 0.0
overall_width: float = 0.0
def is_balanced(self) -> bool:
"""Verifica si el ancho estéreo está balanceado."""
return self.low <= 0.3 and self.high >= 0.5
def get_recommendations(self) -> List[str]:
"""Genera recomendaciones de ajuste de stereo width."""
recs = []
if self.low > 0.3:
recs.append("Reduce stereo width en frecuencias bajas (<250Hz) para evitar conflictos de fase")
if self.high < 0.5:
recs.append("Aumenta stereo width en frecuencias altas (>2kHz) para más ambiente")
if self.mid < 0.3:
recs.append("Considera aumentar ancho estéreo en rango medio para elementos principales")
return recs
def to_dict(self) -> Dict[str, Any]:
return {
"low": self.low,
"mid_low": self.mid_low,
"mid": self.mid,
"high": self.high,
"overall_width": self.overall_width,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "StereoWidth":
return cls(
low=data.get("low", 0.0),
mid_low=data.get("mid_low", 0.0),
mid=data.get("mid", 0.0),
high=data.get("high", 0.0),
overall_width=data.get("overall_width", 0.0),
)
@dataclass
class SimilarityScore:
"""Puntuación de similitud multidimensional entre proyectos.
Atributos:
bpm_score: Similitud de BPM (0.0-1.0)
key_score: Similitud de tonalidad (0.0-1.0)
energy_score: Similitud de curva de energía (0.0-1.0)
spectrum_score: Similitud de espectro (0.0-1.0)
width_score: Similitud de ancho estéreo (0.0-1.0)
...weights: Pesos para cálculo del score total
"""
bpm_score: float = 0.0
key_score: float = 0.0
energy_score: float = 0.0
spectrum_score: float = 0.0
width_score: float = 0.0
bpm_weight: float = 0.20
key_weight: float = 0.15
energy_weight: float = 0.25
spectrum_weight: float = 0.25
width_weight: float = 0.15
@property
def total(self) -> float:
"""Calcula score total ponderado."""
total_weight = sum([self.bpm_weight, self.key_weight, self.energy_weight,
self.spectrum_weight, self.width_weight])
if total_weight == 0:
return 0.0
score = (
self.bpm_score * self.bpm_weight +
self.key_score * self.key_weight +
self.energy_score * self.energy_weight +
self.spectrum_score * self.spectrum_weight +
self.width_score * self.width_weight
) / total_weight
return round(score, 3)
def to_dict(self) -> Dict[str, Any]:
return {
"bpm_score": self.bpm_score,
"key_score": self.key_score,
"energy_score": self.energy_score,
"spectrum_score": self.spectrum_score,
"width_score": self.width_score,
"total": self.total,
"weights": {
"bpm": self.bpm_weight,
"key": self.key_weight,
"energy": self.energy_weight,
"spectrum": self.spectrum_weight,
"width": self.width_weight,
}
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SimilarityScore":
weights = data.get("weights", {})
return cls(
bpm_score=data.get("bpm_score", 0.0),
key_score=data.get("key_score", 0.0),
energy_score=data.get("energy_score", 0.0),
spectrum_score=data.get("spectrum_score", 0.0),
width_score=data.get("width_score", 0.0),
bpm_weight=weights.get("bpm", 0.20),
key_weight=weights.get("key", 0.15),
energy_weight=weights.get("energy", 0.25),
spectrum_weight=weights.get("spectrum", 0.25),
width_weight=weights.get("width", 0.15),
)
# =============================================================================
# PARTE 1 - Análisis y Adaptación (T041-T045)
# =============================================================================
class ProjectAnalyzer:
"""
Analiza proyectos musicales para extraer información clave.
Métodos:
- T041: analyze_project_key() - Detecta key predominante de notas MIDI
- T042: harmonize_track() - Genera notas armonizadas con progresión
- T043: detect_energy_curve() - Grafica energía de la canción
- T044: balance_sections() - Ajusta energía entre secciones
"""
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
KEY_PROFILES = {
'C': [1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0],
'G': [0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1],
'D': [0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
'A': [0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0],
'E': [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
'Am': [1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],
'Em': [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0],
'Dm': [0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
'Gm': [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0],
'Cm': [0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0],
}
def analyze_project_key(self, tracks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
T041: Analiza notas MIDI de múltiples tracks y detecta la key predominante.
Args:
tracks: Lista de tracks con información de notas MIDI
Returns:
Dict con key detectada, confianza, keys alternativas, distribución de notas
"""
all_notes = []
for track in tracks:
if 'notes' in track:
all_notes.extend(track['notes'])
elif 'clips' in track:
for clip in track['clips']:
if 'notes' in clip:
all_notes.extend(clip['notes'])
if not all_notes:
return {"key": "Am", "confidence": 0.0, "alternative_keys": [],
"note_distribution": {}, "scale_type": "minor"}
pitches = [n['pitch'] % 12 for n in all_notes if 'pitch' in n]
if not pitches:
return {"key": "Am", "confidence": 0.0, "alternative_keys": [],
"note_distribution": {}, "scale_type": "minor"}
chroma_counts = Counter(pitches)
total = len(pitches)
distribution = [chroma_counts.get(i, 0) / total for i in range(12)]
best_key, best_score = None, -1
scores = {}
for key_name, profile in self.KEY_PROFILES.items():
correlation = np.corrcoef(distribution, profile)[0, 1]
if np.isnan(correlation):
correlation = 0.0
scores[key_name] = correlation
if correlation > best_score:
best_score, best_key = correlation, key_name
alt_keys = sorted(scores.items(), key=lambda x: x[1], reverse=True)[1:4]
scale_type = "major" if len(best_key) == 1 or best_key[-1] != 'm' else "minor"
return {
"key": best_key,
"confidence": round(best_score, 3),
"alternative_keys": [{"key": k, "confidence": round(s, 3)} for k, s in alt_keys],
"note_distribution": {self.NOTE_NAMES[i]: round(chroma_counts.get(i, 0) / total, 3) for i in range(12)},
"scale_type": scale_type,
"total_notes_analyzed": total,
}
def harmonize_track(self, track_index: int, chord_progression: List[str],
harmony_level: str = "triads") -> Dict[str, Any]:
"""
T042: Genera notas armonizadas para un track basado en progresión de acordes.
Args:
track_index: Índice del track a armonizar
chord_progression: Lista de acordes (e.g., ['Am', 'F', 'C', 'G'])
harmony_level: Nivel de armonía ('triads', 'sevenths', 'extended')
Returns:
Dict con notas generadas y configuración
"""
chord_structures = {
'Am': [0, 3, 7], 'Dm': [2, 5, 9], 'Em': [4, 7, 11],
'Gm': [7, 10, 2], 'Bm': [11, 2, 6],
'C': [0, 4, 7], 'F': [5, 9, 0], 'G': [7, 11, 2],
'D': [2, 6, 9], 'A': [9, 1, 4], 'E': [4, 8, 11],
}
seventh_extensions = {
'Am': 10, 'Dm': 0, 'Em': 2, 'Gm': 5, 'Bm': 9,
'C': 11, 'F': 4, 'G': 6, 'D': 1, 'A': 8, 'E': 3,
}
generated_notes = []
for bar_idx, chord in enumerate(chord_progression):
if chord not in chord_structures:
continue
base_notes = chord_structures[chord][:]
if harmony_level in ('sevenths', 'extended') and chord in seventh_extensions:
base_notes.append(seventh_extensions[chord])
for note_offset in base_notes:
pitch = (69 + note_offset) % 12 + 57
generated_notes.append({
"pitch": pitch,
"start_time": bar_idx * 4.0,
"duration": 4.0,
"velocity": 80,
})
return {
"track_index": track_index,
"chord_progression": chord_progression,
"harmony_level": harmony_level,
"notes_generated": len(generated_notes),
"notes": generated_notes,
"bars_covered": len(chord_progression),
}
def detect_energy_curve(self, arrangement: Dict[str, Any]) -> EnergyCurve:
"""
T043: Detecta y grafica la curva de energía del arreglo.
Args:
arrangement: Dict con información de secciones y tracks
Returns:
EnergyCurve con niveles por compás
"""
sections = arrangement.get('sections', [])
tracks = arrangement.get('tracks', [])
if not sections:
return EnergyCurve(
bars=list(range(0, 64, 4)),
levels=[0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 0.8, 0.6, 0.9, 1.0, 0.7, 0.5, 0.4, 0.3],
section_names=['Intro', 'Build 1', 'Build 2', 'Drop A', 'Break', 'Build 3', 'Drop B', 'Outro']
)
section_energy = {
'intro': 0.30, 'verse': 0.40, 'build': 0.60, 'buildup': 0.60,
'pre-chorus': 0.60, 'drop': 1.00, 'chorus': 0.90, 'hook': 0.90,
'break': 0.40, 'breakdown': 0.40, 'bridge': 0.50, 'outro': 0.30,
}
bars, levels, names, current_bar = [], [], [], 0
for section in sections:
name = section.get('name', 'Unknown').lower()
duration = section.get('duration_bars', 8)
base_energy = next((v for k, v in section_energy.items() if k in name), 0.5)
density = section.get('active_tracks', len(tracks)) / max(len(tracks), 1)
adjusted = base_energy * (0.7 + 0.3 * density)
bars.append(current_bar)
levels.append(round(min(1.0, adjusted), 2))
names.append(name.title())
current_bar += duration
return EnergyCurve(bars=bars, levels=levels, section_names=names)
def balance_sections(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
T044: Ajusta los niveles de energía entre secciones.
Args:
sections: Lista de secciones a balancear
Returns:
Lista de secciones con niveles ajustados
"""
targets = {
'intro': 0.30, 'verse': 0.40, 'build': 0.60, 'buildup': 0.60,
'pre-chorus': 0.60, 'drop': 1.00, 'chorus': 0.90, 'hook': 0.90,
'break': 0.40, 'breakdown': 0.40, 'bridge': 0.50, 'outro': 0.30,
}
balanced = []
for section in sections:
name = section.get('name', 'Unknown').lower()
current = section.get('energy_level', 0.5)
target = next((v for k, v in targets.items() if k in name), 0.5)
adjustment = target - current
suggestions = []
if adjustment > 0.2:
suggestions.extend([
f"Añadir {int(adjustment * 100)}% más elementos",
"Subir volumen de drums"
])
elif adjustment < -0.2:
suggestions.extend([
f"Reducir {int(abs(adjustment) * 100)}% densidad",
"Bajar volumen de pads"
])
balanced.append({
**section,
"target_energy": target,
"current_energy": current,
"adjustment_needed": round(adjustment, 2),
"suggested_adjustments": suggestions,
"is_balanced": abs(adjustment) < 0.15,
})
return balanced
class CounterMelodyGenerator:
"""
Genera contra-melodías que complementan melodías principales.
T045: generate_counter_melody() - Usa intervalos consonantes: 3rds, 6ths
"""
INTERVALS = {
'third_major': 4, 'third_minor': 3, 'fifth': 7,
'sixth_major': 9, 'sixth_minor': 8, 'octave': 12, 'fourth': 5,
}
MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11]
MINOR_SCALE = [0, 2, 3, 5, 7, 8, 10]
def generate_counter_melody(self, main_melody_track: Dict[str, Any],
harmony_level: str = "thirds") -> Dict[str, Any]:
"""
T045: Genera una contra-melodía basada en la melodía principal.
Args:
main_melody_track: Track con la melodía principal
harmony_level: Nivel de armonía ('thirds', 'sixths', 'mixed', 'complementary')
Returns:
Dict con notas de contra-melodía generadas
"""
notes = main_melody_track.get('notes', [])
if not notes:
return {"notes": [], "harmony_level": harmony_level, "status": "empty_source"}
scale = self._detect_scale(notes)
key_center = self._detect_key_center(notes)
counter_notes = []
for note in notes:
pitch = note.get('pitch', 60)
interval = self._select_interval(pitch, scale, harmony_level, key_center)
counter_pitch = self._quantize_to_scale(pitch + interval, scale, key_center)
if harmony_level in ('thirds', 'fifths') and counter_pitch > pitch + 4:
counter_pitch -= 12
elif harmony_level == 'sixths' and counter_pitch < pitch:
counter_pitch += 12
counter_notes.append({
"pitch": counter_pitch,
"start_time": note.get('start_time', 0),
"duration": note.get('duration', 0.25),
"velocity": int(note.get('velocity', 100) * 0.85),
})
return {
"notes": counter_notes,
"harmony_level": harmony_level,
"source_note_count": len(notes),
"generated_note_count": len(counter_notes),
"detected_scale": scale,
"key_center": key_center,
"status": "success",
}
def _detect_scale(self, notes: List[Dict[str, Any]]) -> List[int]:
pitches = [n['pitch'] % 12 for n in notes if 'pitch' in n]
if not pitches:
return self.MINOR_SCALE
counts = Counter(pitches)
major_score = sum(counts.get(p, 0) for p in self.MAJOR_SCALE)
minor_score = sum(counts.get(p, 0) for p in self.MINOR_SCALE)
return self.MAJOR_SCALE if major_score > minor_score else self.MINOR_SCALE
def _detect_key_center(self, notes: List[Dict[str, Any]]) -> int:
pitches = [n['pitch'] % 12 for n in notes if 'pitch' in n]
return Counter(pitches).most_common(1)[0][0] if pitches else 0
def _select_interval(self, pitch: int, scale: List[int], level: str, key_center: int) -> int:
relative = (pitch % 12 - key_center) % 12
if level == "thirds":
interval = self.INTERVALS['third_minor'] if 3 in scale else self.INTERVALS['third_major']
return interval * (-1 if relative in scale[:4] else 1)
elif level == "sixths":
return self.INTERVALS['sixth_minor'] if 3 in scale else self.INTERVALS['sixth_major']
elif level == "fifths":
return -self.INTERVALS['fifth']
elif level == "mixed":
return random.choice([
self.INTERVALS['third_minor'] if 3 in scale else self.INTERVALS['third_major'],
self.INTERVALS['sixth_minor'] if 3 in scale else self.INTERVALS['sixth_major'],
self.INTERVALS['fifth'],
])
return 3
def _quantize_to_scale(self, pitch: int, scale: List[int], key_center: int) -> int:
relative = (pitch % 12 - key_center) % 12
if relative in scale:
return pitch
distances = [(s, abs(relative - s)) for s in scale]
distances.extend([(s + 12, abs(relative - (s + 12))) for s in scale])
distances.extend([(s - 12, abs(relative - (s - 12))) for s in scale])
closest = min(distances, key=lambda x: x[1])[0]
return (pitch // 12) * 12 + ((key_center + closest) % 12)
# =============================================================================
# PARTE 2 - Variación Inteligente (T046-T050)
# =============================================================================
class VariationEngine:
"""
Motor de variación inteligente para loops y secciones.
Métodos:
- T046: variate_loop() - Genera variación de loop
- T047: add_call_and_response() - Call: 2 bars, Response: 2 bars
- T048: generate_breakdown() - Crea breakdown strip down
- T049: generate_drop_variation() - Drop A vs Drop B
- T050: create_outro() - Outro basado en intro con fade
"""
def variate_loop(self, loop_clips: List[Dict[str, Any]],
variation_intensity: float = 0.5) -> List[Dict[str, Any]]:
"""
T046: Genera una variación de loop existente.
Args:
loop_clips: Lista de clips a variar
variation_intensity: 0.0-1.0 (qué tan drástica la variación)
Returns:
Lista de clips variados
"""
varied_clips = []
techniques = []
if variation_intensity > 0.2:
techniques.append('velocity')
if variation_intensity > 0.4:
techniques.append('timing')
if variation_intensity > 0.6:
techniques.append('octave')
if variation_intensity > 0.7:
techniques.append('ornament')
if variation_intensity > 0.8:
techniques.append('rests')
for clip in loop_clips:
notes = clip.get('notes', [])
if not notes:
varied_clips.append(clip)
continue
varied_notes = notes[:]
for technique in techniques:
varied_notes = self._apply_technique(varied_notes, technique, variation_intensity)
varied_clips.append({
**clip,
"notes": varied_notes,
"is_variation": True,
"original_clip": clip.get('name', 'unknown'),
"variation_intensity": variation_intensity,
"techniques_applied": techniques,
})
return varied_clips
def _apply_technique(self, notes: List[Dict[str, Any]],
technique: str, intensity: float) -> List[Dict[str, Any]]:
varied = []
if technique == 'velocity':
for note in notes:
vel = note.get('velocity', 100)
variation = random.uniform(-20, 20) * intensity
varied.append({**note, "velocity": max(1, min(127, int(vel + variation)))})
elif technique == 'timing':
for note in notes:
start = note.get('start_time', 0)
varied.append({**note, "start_time": max(0, start + random.uniform(-0.05, 0.05) * intensity)})
elif technique == 'octave':
for note in notes:
if random.random() < intensity * 0.3:
pitch = note.get('pitch', 60)
varied.append({**note, "pitch": pitch + (12 if random.random() > 0.5 else -12)})
else:
varied.append(note)
elif technique == 'ornament':
for note in notes:
varied.append(note)
if random.random() < intensity * 0.2:
varied.append({
"pitch": note.get('pitch', 60) + random.choice([-1, 1, 2]),
"start_time": note.get('start_time', 0) - 0.02,
"duration": 0.02,
"velocity": min(127, int(note.get('velocity', 100) * 0.8)),
})
elif technique == 'rests':
for note in notes:
if random.random() > intensity * 0.15:
varied.append(note)
return varied if varied else notes
def add_call_and_response(self, phrase_track: Dict[str, Any],
response_length: int = 2) -> Dict[str, Any]:
"""
T047: Añade patrón Call and Response.
Call: 2 bars, Response: 2 bars
Args:
phrase_track: Track con la frase principal
response_length: Longitud del response en compases
Returns:
Dict con notas de call y response
"""
notes = phrase_track.get('notes', [])
if not notes:
return {"call_notes": [], "response_notes": []}
max_time = max(n.get('start_time', 0) for n in notes)
mid_point = max_time / 2
call_notes = [n for n in notes if n.get('start_time', 0) < mid_point]
transposition = random.choice([-7, -5, -3, 0, 3, 5, 7])
response_notes = []
for note in call_notes:
response_notes.append({
"pitch": note.get('pitch', 60) + transposition,
"start_time": note.get('start_time', 0) + mid_point,
"duration": note.get('duration', 0.25) * random.uniform(0.8, 1.2),
"velocity": max(1, min(127, int(note.get('velocity', 100) + random.uniform(-15, 15)))),
})
return {
"call_notes": call_notes,
"response_notes": response_notes,
"transposition_semitones": transposition,
"call_bars": 2,
"response_bars": response_length,
"pattern": "call_response",
}
def generate_breakdown(self, full_sections: List[Dict[str, Any]],
intensity: float = 0.3) -> Dict[str, Any]:
"""
T048: Crea un breakdown strip down reduciendo elementos.
Args:
full_sections: Secciones completas con todos los tracks
intensity: Cuánto mantener (0.3 = 30% de elementos)
Returns:
Dict con sección breakdown generada
"""
if not full_sections:
return {"tracks": [], "duration_bars": 8, "section_type": "breakdown"}
priority_roles = ['melody', 'lead', 'vocal', 'pad', 'atmosphere']
breakdown_tracks = []
for section in full_sections:
tracks = sorted(
section.get('tracks', []),
key=lambda t: priority_roles.index(t.get('role', '')) if t.get('role', '') in priority_roles else 999
)
kept = tracks[:max(1, int(len(tracks) * intensity))]
breakdown_tracks.extend([self._reduce_track_intensity(t, 0.5) for t in kept])
return {
"tracks": breakdown_tracks,
"duration_bars": 8,
"section_type": "breakdown",
"intensity": intensity,
"tracks_count": len(breakdown_tracks),
"original_tracks_count": sum(len(s.get('tracks', [])) for s in full_sections),
}
def _reduce_track_intensity(self, track: Dict[str, Any], factor: float) -> Dict[str, Any]:
return {
**track,
"notes": [{**n, "velocity": int(n.get('velocity', 100) * factor)} for n in track.get('notes', [])],
"volume_reduction_factor": factor,
}
def generate_drop_variation(self, drop_section: Dict[str, Any],
variation_type: str = "alt") -> Dict[str, Any]:
"""
T049: Genera variación de drop (Drop A vs Drop B).
Args:
drop_section: Sección drop original
variation_type: 'alt' para alternativa, 'intense' para más intenso
Returns:
Dict con drop variado
"""
varied_tracks = []
for track in drop_section.get('tracks', []):
notes = track.get('notes', [])
role = track.get('role', '')
if variation_type == "alt":
if role in ['drums', 'percussion']:
varied_notes = self._alternate_drum_pattern(notes)
elif role in ['bass', 'sub']:
varied_notes = self._invert_bass_line(notes)
else:
varied_notes = notes
else:
varied_notes = self._intensify_drums(notes) if role in ['drums', 'percussion'] else notes
varied_tracks.append({
**track,
"notes": varied_notes,
"is_variation": True,
"variation_type": variation_type,
})
return {
"tracks": varied_tracks,
"section_type": f"drop_{variation_type}",
"duration_bars": drop_section.get('duration_bars', 8),
"variation_of": drop_section.get('name', 'unknown'),
}
def _alternate_drum_pattern(self, notes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
varied = []
for note in notes:
if note.get('pitch', 36) in [38, 40] and random.random() < 0.3:
varied.append({**note, "start_time": note.get('start_time', 0) + 0.5})
else:
varied.append(note)
return varied
def _invert_bass_line(self, notes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not notes:
return notes
center = sum(n.get('pitch', 60) for n in notes) / len(notes)
return [{**note, "pitch": int(2 * center - note.get('pitch', 60))} for note in notes]
def _intensify_drums(self, notes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
varied = notes[:]
for note in notes:
if note.get('pitch', 0) in [38, 40]:
varied.append({
**note,
"start_time": note.get('start_time', 0) + 0.25,
"velocity": 40,
"is_ghost": True,
})
return varied
def create_outro(self, intro_section: Dict[str, Any],
fade_duration: int = 8) -> Dict[str, Any]:
"""
T050: Crea un outro basado en la intro con fade out.
Args:
intro_section: Sección intro como base
fade_duration: Duración del fade en compases
Returns:
Dict con sección outro generada
"""
outro_tracks = []
for track in intro_section.get('tracks', []):
faded_notes = []
for note in track.get('notes', []):
fade_factor = max(0.0, 1.0 - (note.get('start_time', 0) / (fade_duration * 4)))
faded_notes.append({**note, "velocity": int(note.get('velocity', 100) * fade_factor)})
outro_tracks.append({**track, "notes": faded_notes, "has_fade": True})
return {
"tracks": outro_tracks,
"section_type": "outro",
"duration_bars": fade_duration,
"based_on": "intro",
"fade_duration": fade_duration,
}
# =============================================================================
# PARTE 3 - Samples Inteligentes (T051-T055)
# =============================================================================
class SampleIntelligence:
"""
Inteligencia avanzada para manipulación de samples.
Métodos:
- T051: find_and_replace_sample() - Busca alternativa similar
- T052: layer_samples() - Layer 2+ samples
- T053: create_sample_chain() - Encadena samples
- T054: generate_from_sample() - Genera canción basada en sample
- T055: create_vocal_chops() - Crea chops mapeados a Drum Rack
"""
def __init__(self, library_path: Optional[str] = None):
self.library_path = library_path or str(
Path(r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\libreria\reggaeton")
)
self._embedding_engine = None
def _get_embedding_engine(self):
if self._embedding_engine is None:
try:
from .embedding_engine import EmbeddingEngine
self._embedding_engine = EmbeddingEngine()
except ImportError:
self._embedding_engine = None
return self._embedding_engine
def find_and_replace_sample(self, current_sample_path: str,
similarity_threshold: float = 0.7) -> Dict[str, Any]:
"""
T051: Busca una alternativa similar al sample actual.
Args:
current_sample_path: Ruta al sample actual
similarity_threshold: Score mínimo de similitud (0.0-1.0)
Returns:
Dict con alternativas encontradas
"""
engine = self._get_embedding_engine()
if engine is None:
return self._fallback_find_similar(current_sample_path, similarity_threshold)
try:
similar = engine.find_similar(current_sample_path, top_n=10)
candidates = [s for s in similar if s.get('similarity', 0) >= similarity_threshold]
return {
"original_sample": current_sample_path,
"alternatives": candidates[:5],
"threshold_used": similarity_threshold,
"matches_found": len(candidates),
}
except:
return self._fallback_find_similar(current_sample_path, similarity_threshold)
def _fallback_find_similar(self, sample_path: str, threshold: float) -> Dict[str, Any]:
sample_dir = Path(sample_path).parent
sample_name = Path(sample_path).stem.lower()
alternatives = []
if sample_dir.exists():
for f in sample_dir.glob("*.wav"):
if f.name.lower() != sample_path.lower():
words1 = set(sample_name.split('_'))
words2 = set(f.stem.lower().split('_'))
if words1 & words2:
sim = len(words1 & words2) / len(words1 | words2)
if sim >= threshold:
alternatives.append({
"path": str(f),
"name": f.name,
"similarity": round(sim, 2),
})
return {
"original_sample": sample_path,
"alternatives": alternatives[:5],
"threshold_used": threshold,
"matches_found": len(alternatives),
"method": "fallback_name_matching",
}
def layer_samples(self, track_index: int, sample_paths: List[str],
volumes: Optional[List[float]] = None) -> Dict[str, Any]:
"""
T052: Crea un layer de 2+ samples.
Args:
track_index: Track donde colocar los samples
sample_paths: Lista de rutas de samples
volumes: Volumen para cada sample (0.0-1.0)
Returns:
Dict con configuración del layer
"""
valid = [p for p in sample_paths if os.path.exists(p)]
if len(valid) < 2:
return {"error": "Se necesitan al menos 2 samples válidos para layer"}
if volumes is None:
volumes = [1.0 / len(valid)] * len(valid)
total = sum(volumes)
if total > 1.0:
volumes = [v / total for v in volumes]
layers = []
for i, (path, vol) in enumerate(zip(valid, volumes)):
layers.append({
"sample_path": path,
"sample_name": Path(path).name,
"volume": round(vol, 3),
"track_position": i,
"pan": 0.0 if i == 0 else random.choice([-0.3, 0.3]),
})
return {
"track_index": track_index,
"num_layers": len(layers),
"layers": layers,
"total_volume": round(sum(l['volume'] for l in layers), 3),
"layering_strategy": "equal_blend" if len(set(volumes)) == 1 else "weighted_blend",
}
def create_sample_chain(self, sample_sequence: List[str],
transition_duration: float = 1.0) -> Dict[str, Any]:
"""
T053: Encadena múltiples samples en secuencia.
Args:
sample_sequence: Lista ordenada de samples
transition_duration: Duración de transiciones en compases
Returns:
Dict con cadena de samples configurada
"""
valid = [p for p in sample_sequence if os.path.exists(p)]
if not valid:
return {"error": "Secuencia vacía"}
chain = []
current_pos = 0.0
for i, path in enumerate(valid):
chain.append({
"sample_path": path,
"sample_name": Path(path).name,
"start_bar": current_pos,
"duration_bars": 4.0,
"transition_in": transition_duration if i > 0 else 0.0,
"transition_out": transition_duration if i < len(valid) - 1 else 0.0,
})
current_pos += 4.0
return {
"chain": chain,
"total_samples": len(chain),
"total_duration_bars": current_pos,
"transition_duration": transition_duration,
"chain_type": "sequential",
}
def generate_from_sample(self, seed_sample_path: str,
style: str = "inspired") -> Dict[str, Any]:
"""
T054: Genera canción/idea basada en un sample seed.
Args:
seed_sample_path: Ruta al sample de inspiración
style: Estilo de generación ('inspired', 'similar', 'remix')
Returns:
Dict con configuración de canción generada
"""
if not os.path.exists(seed_sample_path):
return {"error": f"Sample no encontrado: {seed_sample_path}"}
engine = self._get_embedding_engine()
features = engine.analyzer.get_features(seed_sample_path) if engine and hasattr(engine, 'analyzer') else {}
similar = engine.find_similar(seed_sample_path, top_n=10) if engine else []
bpm = features.get('bpm', 95)
key = features.get('key', 'Am')
structures = {
"inspired": ["intro", "build", "drop", "break", "drop", "outro"],
"similar": ["intro", "verse", "build", "drop", "break", "drop", "outro"],
"remix": ["intro_seed", "build", "drop_seed_mix", "break", "drop_remix", "outro_seed"],
}
return {
"seed_sample": seed_sample_path,
"style": style,
"extracted_features": features,
"suggested_bpm": bpm,
"suggested_key": key,
"structure": structures.get(style, structures["inspired"]),
"similar_samples_for_arrangement": similar[:5],
"recommended_tracks": self._suggest_tracks_for_style(style),
}
def _suggest_tracks_for_style(self, style: str) -> List[Dict[str, Any]]:
base = [
{"role": "kick", "type": "drum", "priority": "high"},
{"role": "snare", "type": "drum", "priority": "high"},
{"role": "hats", "type": "drum", "priority": "medium"},
{"role": "bass", "type": "bass", "priority": "high"},
]
if style == "inspired":
base.extend([
{"role": "melody", "type": "synth", "priority": "medium"},
{"role": "pad", "type": "synth", "priority": "low"},
])
elif style == "similar":
base.extend([
{"role": "lead", "type": "synth", "priority": "high"},
{"role": "arp", "type": "synth", "priority": "medium"},
{"role": "fx", "type": "fx", "priority": "low"},
])
elif style == "remix":
base.extend([
{"role": "seed_chops", "type": "sampler", "priority": "high"},
{"role": "stutter_fx", "type": "fx", "priority": "medium"},
{"role": "vocal_chops", "type": "sampler", "priority": "medium"},
])
return base
def create_vocal_chops(self, vocal_sample_path: str,
num_chops: int = 8) -> Dict[str, Any]:
"""
T055: Crea vocal chops y los mapea a Drum Rack.
Args:
vocal_sample_path: Ruta al sample vocal
num_chops: Número de chops a crear
Returns:
Dict con chops generados y mapeo a pads
"""
if not os.path.exists(vocal_sample_path):
return {"error": f"Vocal sample no encontrado: {vocal_sample_path}"}
positions = [i / num_chops + random.uniform(-0.05, 0.05) for i in range(num_chops)]
chops = []
for i, pos in enumerate(positions):
chops.append({
"chop_index": i,
"pad_note": 36 + i,
"start_position": pos,
"duration": 0.5,
"transient_strength": random.uniform(0.5, 1.0),
})
pattern = []
for i in range(8):
pattern.append({
"note": 36 + (i % num_chops),
"start_time": i * 0.5,
"velocity": 100 if i % 4 == 0 else 80,
})
return {
"source_sample": vocal_sample_path,
"num_chops": len(chops),
"chops": chops,
"drum_rack_mapping": {
"base_note": 36,
"note_range": f"36-{36 + len(chops) - 1}",
},
"suggested_pattern": pattern,
}
# =============================================================================
# PARTE 4 - Referencia y Comparación (T056-T060)
# =============================================================================
class ReferenceMatcher:
"""
Compara proyectos con referencias profesionales y adapta.
Métodos:
- T056: match_reference_energy() - Ajusta energía
- T057: match_reference_spectrum() - Ajusta EQ
- T058: match_reference_width() - Ajusta stereo width
- T059: generate_similarity_report() - Score por dimensión
- T060: adapt_to_reference_style() - Adapta estructura e instrumentación
"""
def match_reference_energy(self, project_tracks: List[Dict[str, Any]],
reference_energy_curve: EnergyCurve) -> Dict[str, Any]:
"""
T056: Ajusta la energía del proyecto para coincidir con referencia.
Args:
project_tracks: Tracks del proyecto actual
reference_energy_curve: Curva de energía de referencia
Returns:
Dict con ajustes sugeridos
"""
current = self._analyze_project_energy(project_tracks)
adjustments = []
for i, (bar, target) in enumerate(zip(reference_energy_curve.bars,
reference_energy_curve.levels)):
cur = current.get_level_at(bar)
diff = target - cur
if abs(diff) > 0.1:
adjustments.append({
"bar": bar,
"section": reference_energy_curve.section_names[i] if i < len(reference_energy_curve.section_names) else "unknown",
"target_energy": round(target, 2),
"current_energy": round(cur, 2),
"adjustment": round(diff, 2),
"suggestion": self._energy_suggestion(diff),
})
return {
"reference_curve": reference_energy_curve.to_dict(),
"current_curve": current.to_dict(),
"adjustments_needed": len(adjustments),
"adjustments": adjustments,
"overall_match_score": self._curve_similarity(current, reference_energy_curve),
}
def _analyze_project_energy(self, tracks: List[Dict[str, Any]]) -> EnergyCurve:
bars, levels = [], []
for bar in range(0, 64, 4):
energy = sum(
(np.mean([n.get('velocity', 100) for n in t.get('notes', []) if bar <= n.get('start_time', 0) < bar + 4] or [0]) / 127.0) *
min(1.0, len([n for n in t.get('notes', []) if bar <= n.get('start_time', 0) < bar + 4]) / 16)
for t in tracks
) / max(len(tracks), 1)
bars.append(bar)
levels.append(min(1.0, energy))
return EnergyCurve(bars=bars, levels=levels)
def _energy_suggestion(self, diff: float) -> str:
if diff > 0.3:
return "Añadir capas de drums y subir volumen general"
elif diff > 0.15:
return "Aumentar elementos percusivos o volumen de drums"
elif diff > 0:
return "Subir ligeramente volumen de elementos principales"
elif diff < -0.3:
return "Reducir drásticamente densidad de tracks"
elif diff < -0.15:
return "Bajar volumen de pads/synths"
return "Ajuste fino de balance"
def _curve_similarity(self, c1: EnergyCurve, c2: EnergyCurve) -> float:
min_len = min(len(c1.levels), len(c2.levels))
if min_len < 2:
return 0.5
corr = np.corrcoef(np.array(c1.levels[:min_len]), np.array(c2.levels[:min_len]))[0, 1]
return round((corr + 1) / 2, 3) if not np.isnan(corr) else 0.5
def match_reference_spectrum(self, project_eq: Dict[str, Any],
reference_spectrum: SpectrumProfile) -> Dict[str, Any]:
"""
T057: Compara y ajusta EQ para coincidir con referencia.
Args:
project_eq: EQ actual del proyecto
reference_spectrum: Perfil espectral de referencia
Returns:
Dict con recomendaciones de EQ
"""
current = project_eq.get('bands', {})
bands = [
('low', reference_spectrum.low_energy, current.get('low', 0.5)),
('low_mid', reference_spectrum.low_mid_energy, current.get('low_mid', 0.5)),
('mid', reference_spectrum.mid_energy, current.get('mid', 0.5)),
('high_mid', reference_spectrum.high_mid_energy, current.get('high_mid', 0.5)),
('high', reference_spectrum.high_energy, current.get('high', 0.5)),
]
eq_adj = []
for name, target, cur in bands:
diff = target - cur
if abs(diff) > 0.05:
eq_adj.append({
"band": name,
"target_db": round(target * 12 - 6, 1),
"current_db": round(cur * 12 - 6, 1),
"adjustment_db": round(diff * 12, 1),
"action": "boost" if diff > 0 else "cut",
})
distance = np.linalg.norm(np.array([b[1] for b in bands]) - np.array([b[2] for b in bands]))
return {
"reference_spectrum": reference_spectrum.to_dict(),
"current_eq": project_eq,
"eq_adjustments": eq_adj,
"spectrum_match_score": round(max(0, 1 - distance / 2), 3),
"needs_eq_work": len(eq_adj) > 2,
}
def match_reference_width(self, project_stereo: Dict[str, Any],
reference_width: StereoWidth) -> Dict[str, Any]:
"""
T058: Compara y ajusta ancho estéreo para coincidir con referencia.
Args:
project_stereo: Ancho estéreo actual del proyecto
reference_width: Ancho estéreo de referencia
Returns:
Dict con recomendaciones de ancho estéreo
"""
current = StereoWidth(
low=project_stereo.get('low', 0.1),
mid_low=project_stereo.get('mid_low', 0.3),
mid=project_stereo.get('mid', 0.5),
high=project_stereo.get('high', 0.7),
)
comps = [
("low", current.low, reference_width.low, 0.2),
("mid_low", current.mid_low, reference_width.mid_low, 0.4),
("mid", current.mid, reference_width.mid, 0.5),
("high", current.high, reference_width.high, 0.6),
]
width_adj = []
for band, cur, ref, tol in comps:
diff = cur - ref
if abs(diff) > tol:
width_adj.append({
"band": band,
"current_width": round(cur, 2),
"reference_width": round(ref, 2),
"difference": round(diff, 2),
"action": "narrow" if diff > 0 else "widen",
"suggestion": self._width_suggestion(band, diff),
})
match_score = max(0, 1 - np.mean([abs(c[1] - c[2]) for c in comps]))
return {
"reference_width": reference_width.to_dict(),
"current_width": current.to_dict(),
"width_adjustments": width_adj,
"width_match_score": round(match_score, 3),
"is_balanced": current.is_balanced(),
}
def _width_suggestion(self, band: str, diff: float) -> str:
if band == "low":
return "Usar Utility o EQ para mono en frecuencias bajas" if diff > 0 else "Más mono en bajos mejora potencia"
elif band == "high":
return "Añadir chorus o delay corto para ampliar agudos" if diff < 0 else "Más estrecho para evitar perder foco"
return "Considerar paneo más amplio en rango medio" if diff < 0 else "Más estrecho para mejor cohesión"
def generate_similarity_report(self, project: Dict[str, Any],
reference: Dict[str, Any]) -> Dict[str, Any]:
"""
T059: Genera reporte detallado de similitud por dimensiones.
Args:
project: Datos del proyecto actual
reference: Datos de la referencia
Returns:
Dict con SimilarityScore desglosado
"""
scores = SimilarityScore()
bpm_diff = abs(project.get('tempo', 120) - reference.get('tempo', 120))
scores.bpm_score = max(0, 1 - (bpm_diff / 30))
p_key, r_key = project.get('key', ''), reference.get('key', '')
scores.key_score = 1.0 if p_key == r_key else (0.5 if p_key and r_key and p_key[0] == r_key[0] else 0.0)
p_energy, r_energy = project.get('energy_curve', {}), reference.get('energy_curve', {})
if p_energy and r_energy:
p_l, r_l = p_energy.get('levels', []), r_energy.get('levels', [])
if p_l and r_l:
min_len = min(len(p_l), len(r_l))
corr = np.corrcoef(p_l[:min_len], r_l[:min_len])[0, 1]
scores.energy_score = (corr + 1) / 2 if not np.isnan(corr) else 0.5
p_spec, r_spec = project.get('spectrum', {}), reference.get('spectrum', {})
if p_spec and r_spec:
distance = np.linalg.norm(
np.array([p_spec.get(k, 0) for k in ['low', 'mid', 'high']]) -
np.array([r_spec.get(k, 0) for k in ['low', 'mid', 'high']])
)
scores.spectrum_score = max(0, 1 - distance / 3)
p_width, r_width = project.get('stereo_width', {}), reference.get('stereo_width', {})
if p_width and r_width:
diffs = [abs(p_width.get(k, 0) - r_width.get(k, 0)) for k in ['low', 'mid', 'high']]
scores.width_score = max(0, 1 - np.mean(diffs))
total = scores.total
interpretation = (
"Muy similar" if total >= 0.85 else
"Similar" if total >= 0.70 else
"Moderadamente similar" if total >= 0.55 else
"Poco similar" if total >= 0.40 else
"Diferente"
)
return {
"similarity_scores": scores.to_dict(),
"total_similarity": total,
"interpretation": interpretation,
"dimension_analysis": {
"bpm": {"project": project.get('tempo', 0), "reference": reference.get('tempo', 0), "score": scores.bpm_score},
"key": {"project": p_key, "reference": r_key, "score": scores.key_score},
"energy": {"score": scores.energy_score},
"spectrum": {"score": scores.spectrum_score},
"width": {"score": scores.width_score},
},
}
def adapt_to_reference_style(self, project: Dict[str, Any],
reference_style: str) -> Dict[str, Any]:
"""
T060: Adapta estructura e instrumentación al estilo de referencia.
Args:
project: Proyecto a adaptar
reference_style: Estilo de referencia ('pop', 'edm', 'hiphop', 'reggaeton')
Returns:
Dict con adaptaciones sugeridas
"""
profiles = {
'reggaeton': {
'structure': ['intro', 'verse', 'build', 'drop', 'break', 'drop', 'outro'],
'bpm_range': (85, 105),
'key_type': 'minor',
'instruments': ['kick', 'snare', 'dembow_hats', 'bass', 'synth_lead'],
'width': 'narrow_low_wide_high',
},
'pop': {
'structure': ['intro', 'verse', 'prechorus', 'chorus', 'verse', 'chorus', 'bridge', 'chorus', 'outro'],
'bpm_range': (90, 130),
'key_type': 'major',
'instruments': ['kick', 'snare', 'hats', 'bass', 'pad', 'lead_vocal'],
'width': 'balanced',
},
'edm': {
'structure': ['intro', 'build', 'drop', 'break', 'build', 'drop', 'outro'],
'bpm_range': (120, 140),
'key_type': 'minor',
'instruments': ['kick', 'snare', 'hats', 'sub_bass', 'synth_lead', 'fx'],
'width': 'wide',
},
'hiphop': {
'structure': ['intro', 'verse', 'hook', 'verse', 'hook', 'bridge', 'hook', 'outro'],
'bpm_range': (70, 100),
'key_type': 'minor',
'instruments': ['kick', 'snare', 'hats', '808_bass', 'sample', 'vocal'],
'width': 'centered',
},
}
profile = profiles.get(reference_style.lower(), profiles['reggaeton'])
current_tracks = project.get('tracks', [])
current_bpm = project.get('tempo', 120)
current_roles = {t.get('role', 'unknown') for t in current_tracks}
changes = [
{"action": "add", "instrument": i, "reason": "Característico del estilo"}
for i in profile['instruments'] if i not in current_roles
]
changes.extend([
{"action": "consider_remove", "instrument": r, "reason": "No típico del estilo"}
for r in current_roles if r not in profile['instruments']
])
priorities = []
if not (profile['bpm_range'][0] <= current_bpm <= profile['bpm_range'][1]):
priorities.append("adjust_bpm")
if len(project.get('structure', [])) < len(profile['structure']):
priorities.append("extend_structure")
if [i for i in profile['instruments'] if i not in current_roles]:
priorities.append("add_missing_instruments")
if not priorities:
priorities.append("fine_tune_mix")
return {
"target_style": reference_style,
"current_structure": project.get('structure', []),
"suggested_structure": profile['structure'],
"bpm_adjustment": {
"current": current_bpm,
"target_range": profile['bpm_range'],
"suggested": sum(profile['bpm_range']) // 2,
},
"instrumentation_changes": changes,
"stereo_width_target": profile['width'],
"adaptation_priority": priorities,
}
# =============================================================================
# AGENTE 13 - EXTENDED CHORDS ENGINE (Acordes Ricos)
# =============================================================================
# Extended chord structures with intervals (semitones from root)
CHORD_STRUCTURES = {
# Basic triads and sevenths (existing)
'maj': [0, 4, 7],
'min': [0, 3, 7],
'dim': [0, 3, 6],
'aug': [0, 4, 8],
'maj7': [0, 4, 7, 11],
'min7': [0, 3, 7, 10],
'dom7': [0, 4, 7, 10],
'dim7': [0, 3, 6, 9],
'half_dim': [0, 3, 6, 10],
'min_maj7': [0, 3, 7, 11],
# 9ths
'maj9': [0, 4, 7, 11, 14], # 1, 3, 5, 7, 9
'min9': [0, 3, 7, 10, 14], # 1, b3, 5, b7, 9
'dom9': [0, 4, 7, 10, 14], # 1, 3, 5, b7, 9
'9': [0, 4, 7, 10, 14], # Alias for dom9
'maj_add9': [0, 4, 7, 14], # 1, 3, 5, 9 (no 7th)
'min_add9': [0, 3, 7, 14], # 1, b3, 5, 9 (no 7th)
# 11ths
'maj11': [0, 4, 7, 11, 14, 17], # 1, 3, 5, 7, 9, 11
'min11': [0, 3, 7, 10, 14, 17], # 1, b3, 5, b7, 9, 11
'dom11': [0, 4, 7, 10, 14, 17], # 1, 3, 5, b7, 9, 11
'11': [0, 4, 7, 10, 14, 17], # Alias for dom11
# 13ths
'maj13': [0, 4, 7, 11, 14, 17, 21], # 1, 3, 5, 7, 9, 11, 13
'min13': [0, 3, 7, 10, 14, 17, 21], # 1, b3, 5, b7, 9, 11, 13
'dom13': [0, 4, 7, 10, 14, 17, 21], # 1, 3, 5, b7, 9, 11, 13
'13': [0, 4, 7, 10, 14, 17, 21], # Alias for dom13
# Sus chords
'sus2': [0, 2, 7], # 1, 2, 5
'sus4': [0, 5, 7], # 1, 4, 5
'7sus4': [0, 5, 7, 10], # 1, 4, 5, b7
'9sus4': [0, 5, 7, 10, 14], # 1, 4, 5, b7, 9
# Altered dominant chords
'7b5': [0, 4, 6, 10], # 1, 3, b5, b7
'7b9': [0, 4, 7, 10, 13], # 1, 3, 5, b7, b9
'7#9': [0, 4, 7, 10, 15], # 1, 3, 5, b7, #9
'7#11': [0, 4, 7, 10, 18], # 1, 3, 5, b7, #11
'7b13': [0, 4, 7, 10, 20], # 1, 3, 5, b7, b13
'alt': [0, 4, 6, 10, 13, 20], # Altered - 1, 3, b5, b7, b9, b13
}
# Chord type categories for UI/selection
CHORD_CATEGORIES = {
'triads': ['maj', 'min', 'dim', 'aug'],
'sevenths': ['maj7', 'min7', 'dom7', 'dim7', 'half_dim', 'min_maj7'],
'ninths': ['maj9', 'min9', 'dom9', '9', 'maj_add9', 'min_add9'],
'elevenths': ['maj11', 'min11', 'dom11', '11'],
'thirteenths': ['maj13', 'min13', 'dom13', '13'],
'suspended': ['sus2', 'sus4', '7sus4', '9sus4'],
'altered': ['7b5', '7b9', '7#9', '7#11', '7b13', 'alt'],
}
# Root note to MIDI mapping (C4 = 60)
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
def parse_chord_name(chord_name: str) -> Tuple[str, int]:
"""Parse a chord name like 'Cmaj9' or 'Am7' into (root_note, quality).
Returns:
Tuple of (root_note_name, root_midi_number)
"""
chord_name = chord_name.strip()
# Determine root note (handle sharps and flats)
root = None
root_idx = 0
if len(chord_name) > 1 and chord_name[1] in '#b':
root = chord_name[:2]
quality = chord_name[2:]
else:
root = chord_name[0].upper()
quality = chord_name[1:]
# Convert root to MIDI note number (C4 = 60)
root_clean = root.replace('b', '-').replace('#', '+')
try:
root_idx = NOTE_NAMES.index(root.replace('b', '').replace('#', ''))
if 'b' in root or '-' in root:
root_idx = (root_idx - 1) % 12
if '#' in root or '+' in root:
root_idx = (root_idx + 1) % 12
except ValueError:
root_idx = 0
# Default to C4 if no root found
root_midi = 60 + root_idx # C4
return quality, root_midi
def get_chord_notes(chord_name: str, root_midi: int = None) -> List[int]:
"""Generate MIDI note numbers for a named chord.
Args:
chord_name: Chord name like 'maj9', 'min7', '7b9'
root_midi: Root note MIDI number (default: 60 for C4)
Returns:
List of MIDI note numbers for the chord tones
"""
if root_midi is None:
quality, root_midi = parse_chord_name(chord_name)
else:
quality = chord_name
# Normalize quality name
quality = quality.lower().replace('-', 'min').replace('m7b5', 'half_dim')
# Get intervals
intervals = CHORD_STRUCTURES.get(quality, CHORD_STRUCTURES.get('maj'))
# Calculate notes
return [(root_midi + interval) % 128 for interval in intervals]
# =============================================================================
# VOICE LEADING FUNCTIONS
# =============================================================================
def drop_2_voicing(chord_notes: List[int]) -> List[int]:
"""Create a drop-2 voicing by lowering the second note from top by an octave.
Drop-2 voicings are widely used in jazz and create smooth voice leading.
The second note from the top is dropped down one octave.
Args:
chord_notes: List of MIDI notes (sorted high to low or low to high)
Returns:
Re-voiced chord with drop-2 spacing
"""
if len(chord_notes) < 3:
return chord_notes[:]
# Sort from high to low
sorted_notes = sorted(chord_notes, reverse=True)
# Drop the second note from top down an octave
voicing = sorted_notes[:]
voicing[1] = voicing[1] - 12 # Drop down one octave
# Re-sort to keep proper order
return sorted(voicing)
def drop_3_voicing(chord_notes: List[int]) -> List[int]:
"""Create a drop-3 voicing by lowering the third note from top by an octave.
Drop-3 voicings have a wider spacing between the lowest and highest notes,
creating an open, airy sound.
Args:
chord_notes: List of MIDI notes
Returns:
Re-voiced chord with drop-3 spacing
"""
if len(chord_notes) < 4:
return drop_2_voicing(chord_notes)
# Sort from high to low
sorted_notes = sorted(chord_notes, reverse=True)
# Drop the third note from top down an octave
voicing = sorted_notes[:]
voicing[2] = voicing[2] - 12
# Re-sort
return sorted(voicing)
def open_voicing(chord_notes: List[int], spread: int = 12) -> List[int]:
"""Create an open voicing by spreading chord tones across octaves.
This creates a wide, orchestral sound by distributing notes.
Args:
chord_notes: List of MIDI notes
spread: Semitones to spread (default 12 = one octave)
Returns:
Re-voiced chord with open spacing
"""
if len(chord_notes) < 2:
return chord_notes[:]
# Keep bass note, spread others upward
bass = min(chord_notes)
others = sorted([n for n in chord_notes if n != bass])
result = [bass]
for i, note in enumerate(others):
# Spread each successive note higher
spread_note = note + (i * spread // len(others))
# Keep within MIDI range
while spread_note > 127:
spread_note -= 12
result.append(spread_note)
return sorted(result)
def minimal_movement(current_chord: List[int], next_chord: List[int]) -> List[int]:
"""Optimize voice leading between two chords for minimal movement.
This finds the closest voicing of next_chord to current_chord,
minimizing the total distance between voices.
Args:
current_chord: Current chord notes (list of MIDI numbers)
next_chord: Target chord notes (list of MIDI numbers)
Returns:
Optimized next chord with minimal voice movement
"""
if not current_chord or not next_chord:
return next_chord[:]
current_sorted = sorted(current_chord)
next_sorted = sorted(next_chord)
# Try different octave transpositions for each note in next_chord
# to find the configuration with minimal total movement
best_voicing = next_sorted[:]
best_distance = float('inf')
# Generate possible octave shifts for each note
def get_octave_options(note, target_note):
"""Get the note shifted by different octaves near the target."""
options = []
for shift in [-24, -12, 0, 12, 24]:
shifted = note + shift
if 0 <= shifted <= 127:
options.append(shifted)
return options
# For smaller chords, try all combinations
if len(next_sorted) <= 4:
from itertools import product
options_per_note = [get_octave_options(n, current_sorted[0]) for n in next_sorted]
for combination in product(*options_per_note):
# Calculate total distance to current chord
# Match voices (lower to lower, higher to higher)
combo_sorted = sorted(combination)
# Pad to match lengths if needed
curr = current_sorted[:]
nxt = combo_sorted[:]
while len(curr) < len(nxt):
curr.append(curr[-1] if curr else 60)
while len(nxt) < len(curr):
nxt.append(nxt[-1] if nxt else 60)
distance = sum(abs(c - n) for c, n in zip(curr, nxt))
if distance < best_distance:
best_distance = distance
best_voicing = list(combination)
else:
# For larger chords, use greedy approach
best_voicing = []
for i, next_note in enumerate(next_sorted):
target = current_sorted[min(i, len(current_sorted) - 1)]
options = get_octave_options(next_note, target)
best = min(options, key=lambda x: abs(x - target))
best_voicing.append(best)
return sorted(best_voicing)
def voice_chord_progression(chords: List[List[int]],
voicing_type: str = "drop2") -> List[List[int]]:
"""Apply voice leading to an entire chord progression.
Args:
chords: List of chord note lists
voicing_type: 'drop2', 'drop3', 'open', or 'minimal'
Returns:
List of voiced chords with smooth voice leading
"""
if not chords:
return []
voiced = []
for i, chord in enumerate(chords):
# Apply voicing type
if voicing_type == "drop2":
current = drop_2_voicing(chord)
elif voicing_type == "drop3":
current = drop_3_voicing(chord)
elif voicing_type == "open":
current = open_voicing(chord)
elif voicing_type == "minimal" and i > 0:
current = minimal_movement(voiced[-1], chord)
else:
current = sorted(chord)
# If minimal movement, also apply to transitions
if voicing_type == "minimal" and i > 0:
current = minimal_movement(voiced[-1], current)
voiced.append(current)
return voiced
class ExtendedChordsEngine:
"""Engine for generating advanced chord voicings and progressions.
Provides rich harmonic content with extended chords (9ths, 11ths, 13ths),
suspended chords, and altered dominants. Includes intelligent voice leading.
Features:
- Extended chord generation (9ths, 11ths, 13ths)
- Suspended and altered chords
- Jazz-style voice leading (drop-2, drop-3, open voicings)
- Smooth voice movement between chords
"""
def __init__(self):
self.chord_structures = CHORD_STRUCTURES
self.categories = CHORD_CATEGORIES
def generate_extended_chord(self, root: str, chord_type: str,
octave: int = 4, voicing: str = "default") -> Dict[str, Any]:
"""Generate an extended chord with specified voicing.
Args:
root: Root note (e.g., 'C', 'F#', 'Bb')
chord_type: Chord quality (e.g., 'maj9', 'min11', '7b9')
octave: Octave number (4 = middle C)
voicing: Voicing type ('default', 'drop2', 'drop3', 'open')
Returns:
Dict with chord notes, MIDI numbers, and metadata
"""
# Parse root note to MIDI
root_clean = root.upper()
if len(root_clean) > 1 and root_clean[1] == 'B':
root_clean = root_clean[0] + 'b'
if len(root_clean) > 1 and root_clean[1] == '#':
root_clean = root_clean[0] + '#'
# Get root index
base_note = root_clean[0]
try:
root_idx = NOTE_NAMES.index(base_note)
except ValueError:
root_idx = 0
# Apply accidentals
if 'b' in root_clean or '' in root_clean:
root_idx = (root_idx - 1) % 12
if '#' in root_clean or '' in root_clean:
root_idx = (root_idx + 1) % 12
# Calculate root MIDI note
root_midi = (octave + 1) * 12 + root_idx # C4 = 60
# Normalize chord type
chord_type = chord_type.lower().replace('-', 'min').replace('major', 'maj')
# Get chord intervals
intervals = self.chord_structures.get(chord_type)
if intervals is None:
# Try to find similar
for key in self.chord_structures:
if chord_type in key or key in chord_type:
intervals = self.chord_structures[key]
chord_type = key
break
if intervals is None:
intervals = self.chord_structures['maj']
chord_type = 'maj'
# Generate notes
notes = [(root_midi + interval) % 128 for interval in intervals]
# Apply voicing
if voicing == "drop2":
notes = drop_2_voicing(notes)
elif voicing == "drop3":
notes = drop_3_voicing(notes)
elif voicing == "open":
notes = open_voicing(notes)
# Generate note names
note_names = []
for note in notes:
note_idx = note % 12
octave_num = (note // 12) - 1
note_names.append(f"{NOTE_NAMES[note_idx]}{octave_num}")
return {
"root": root,
"chord_type": chord_type,
"voicing": voicing,
"octave": octave,
"midi_notes": notes,
"note_names": note_names,
"intervals": intervals,
"category": self._get_category(chord_type),
}
def _get_category(self, chord_type: str) -> str:
"""Get the category for a chord type."""
for cat, types in self.categories.items():
if chord_type in types:
return cat
return "other"
def generate_chord_progression(self, roots: List[str], chord_types: List[str],
voicing: str = "minimal") -> List[Dict[str, Any]]:
"""Generate a chord progression with smooth voice leading.
Args:
roots: List of root notes
chord_types: List of chord types (parallel to roots)
voicing: Voice leading type ('minimal', 'drop2', 'drop3', 'open')
Returns:
List of chord dictionaries with voiced notes
"""
# Generate basic chords
chords = []
for root, ctype in zip(roots, chord_types):
chord = self.generate_extended_chord(root, ctype, voicing="default")
chords.append(chord)
# Apply voice leading
if voicing == "minimal" and len(chords) > 1:
# Apply minimal movement between successive chords
for i in range(1, len(chords)):
prev_notes = chords[i-1]["midi_notes"]
curr_notes = chords[i]["midi_notes"]
voiced = minimal_movement(prev_notes, curr_notes)
chords[i]["midi_notes"] = voiced
# Update note names
chords[i]["note_names"] = [
f"{NOTE_NAMES[n % 12]}{(n // 12) - 1}" for n in voiced
]
elif voicing in ("drop2", "drop3", "open"):
for chord in chords:
notes = chord["midi_notes"]
if voicing == "drop2":
notes = drop_2_voicing(notes)
elif voicing == "drop3":
notes = drop_3_voicing(notes)
elif voicing == "open":
notes = open_voicing(notes)
chord["midi_notes"] = notes
chord["voicing"] = voicing
chord["note_names"] = [
f"{NOTE_NAMES[n % 12]}{(n // 12) - 1}" for n in notes
]
return chords
def get_available_chord_types(self, category: str = None) -> List[str]:
"""Get list of available chord types, optionally filtered by category.
Args:
category: Optional category filter ('ninths', 'elevenths', etc.)
Returns:
List of chord type names
"""
if category and category in self.categories:
return self.categories[category][:]
# Return all types
all_types = []
for types in self.categories.values():
all_types.extend(types)
return sorted(set(all_types))
def suggest_chords_for_key(self, key: str) -> Dict[str, List[str]]:
"""Suggest appropriate chords for a given key.
Args:
key: Musical key (e.g., 'C', 'Am', 'F#')
Returns:
Dict with chord suggestions by degree
"""
# Parse key - detect minor BEFORE upper-casing to preserve 'm' suffix
original_key = key.strip()
is_minor = original_key.endswith('m') or original_key.endswith('min') or original_key.endswith('minor')
# Also check for lowercase single-letter keys (e.g., 'a' = A minor convention)
if not is_minor and len(original_key) == 1 and original_key.islower():
is_minor = True
key = original_key.upper()
base = key[0]
if len(key) > 1 and key[1] in '#B':
base = key[:2]
# Determine scale degrees
if is_minor:
# Natural minor scale degrees
degrees = ['min', 'dim', 'maj', 'min', 'min', 'maj', 'maj']
extensions = ['min7', 'half_dim', 'maj7', 'min7', 'min7', 'maj7', '7']
else:
# Major scale degrees
degrees = ['maj', 'min', 'min', 'maj', 'maj', 'min', 'dim']
extensions = ['maj7', 'min7', 'min7', 'maj7', '7', 'min7', 'half_dim']
# Extended jazz voicings
extended_minor = ['min9', 'min11', 'min6', 'min9', 'min11', 'maj9', '7b9']
extended_major = ['maj9', 'min9', 'min11', 'maj13', '13', 'min9', '7alt']
roman = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
if is_minor:
roman = ['i', 'ii°', 'III', 'iv', 'v', 'VI', 'VII']
return {
"key": key,
"is_minor": is_minor,
"basic": [f"{base}{deg}" for deg in degrees],
"sevenths": [f"{base}{ext}" for ext in extensions],
"extended": extended_minor if is_minor else extended_major,
"roman_numerals": roman,
}
# =============================================================================
# FUNCIONES DE CONVENIENCIA
# =============================================================================
def analyze_project_key(tracks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Función de conveniencia para analizar key de proyecto."""
analyzer = ProjectAnalyzer()
return analyzer.analyze_project_key(tracks)
def harmonize_track(track_index: int, chord_progression: List[str]) -> Dict[str, Any]:
"""Función de conveniencia para armonizar track."""
analyzer = ProjectAnalyzer()
return analyzer.harmonize_track(track_index, chord_progression)
def generate_counter_melody(main_melody_track: Dict[str, Any],
harmony_level: str = "thirds") -> Dict[str, Any]:
"""Función de conveniencia para generar contra-melodía."""
generator = CounterMelodyGenerator()
return generator.generate_counter_melody(main_melody_track, harmony_level)
def variate_loop(loop_clips: List[Dict[str, Any]],
variation_intensity: float = 0.5) -> List[Dict[str, Any]]:
"""Función de conveniencia para variar loop."""
engine = VariationEngine()
return engine.variate_loop(loop_clips, variation_intensity)
def create_vocal_chops(vocal_sample_path: str, num_chops: int = 8) -> Dict[str, Any]:
"""Función de conveniencia para crear vocal chops."""
intelligence = SampleIntelligence()
return intelligence.create_vocal_chops(vocal_sample_path, num_chops)
# =============================================================================
# EXPORTS
# =============================================================================
__all__ = [
# Dataclasses
"EnergyCurve",
"SpectrumProfile",
"StereoWidth",
"SimilarityScore",
# Clases principales - Parte 1 (T041-T045)
"ProjectAnalyzer",
"CounterMelodyGenerator",
# Clases principales - Parte 2 (T046-T050)
"VariationEngine",
# Clases principales - Parte 3 (T051-T055)
"SampleIntelligence",
# Clases principales - Parte 4 (T056-T060)
"ReferenceMatcher",
# Agente 13 - Extended Chords Engine
"ExtendedChordsEngine",
"CHORD_STRUCTURES",
"CHORD_CATEGORIES",
# Voice leading functions
"drop_2_voicing",
"drop_3_voicing",
"open_voicing",
"minimal_movement",
"voice_chord_progression",
"get_chord_notes",
"parse_chord_name",
# Funciones de conveniencia
"analyze_project_key",
"harmonize_track",
"generate_counter_melody",
"variate_loop",
"create_vocal_chops",
]