- Add _cmd_create_arrangement_audio_pattern with 5-method fallback chain - Method 1: track.insert_arrangement_clip() [Live 12+] - Method 2: track.create_audio_clip() [Live 11+] - Method 3: arrangement_clips.add_new_clip() [Live 12+] - Method 4: Session->duplicate_clip_to_arrangement [Legacy] - Method 5: Session->Recording [Universal] - Add _cmd_duplicate_clip_to_arrangement for session-to-arrangement workflow - Update skills documentation - Verified: 3 clips created at positions [0, 4, 8] in Arrangement View Closes: Audio injection in Arrangement View
1684 lines
53 KiB
Python
1684 lines
53 KiB
Python
"""
|
|
Arrangement Engine - Arrangement View and Automation Engine
|
|
|
|
Este módulo proporciona herramientas avanzadas para trabajar con Arrangement View
|
|
en Ableton Live, incluyendo construcción de estructuras, automatización de parámetros,
|
|
creación de efectos FX y procesamiento de samples.
|
|
|
|
Autor: AbletonMCP_AI
|
|
"""
|
|
import logging
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional, Any, Tuple, Union
|
|
from pathlib import Path
|
|
import os
|
|
import math
|
|
|
|
logger = logging.getLogger("ArrangementEngine")
|
|
|
|
|
|
# =============================================================================
|
|
# CONSTANTES Y CONFIGURACIONES
|
|
# =============================================================================
|
|
|
|
# Estructuras de arrangement predefinidas
|
|
ARRANGEMENT_STRUCTURES = {
|
|
"intro_build_drop_break_outro": [
|
|
("intro", 8),
|
|
("build", 8),
|
|
("drop", 16),
|
|
("break", 8),
|
|
("drop2", 16),
|
|
("outro", 8),
|
|
],
|
|
"intro_drop_break_outro": [
|
|
("intro", 8),
|
|
("drop", 16),
|
|
("break", 8),
|
|
("outro", 8),
|
|
],
|
|
"extended": [
|
|
("intro", 16),
|
|
("build", 8),
|
|
("drop", 16),
|
|
("break1", 8),
|
|
("build2", 8),
|
|
("drop2", 16),
|
|
("break2", 8),
|
|
("peak", 8),
|
|
("outro", 16),
|
|
],
|
|
}
|
|
|
|
# Configuraciones de automatización por defecto
|
|
DEFAULT_FILTER_FREQ_START = 200.0
|
|
DEFAULT_FILTER_FREQ_END = 20000.0
|
|
DEFAULT_REVERB_WET_START = 0.0
|
|
DEFAULT_REVERB_WET_END = 0.5
|
|
DEFAULT_VOLUME_START = 0.0
|
|
DEFAULT_VOLUME_END = 0.85
|
|
DEFAULT_DELAY_FEEDBACK_START = 0.1
|
|
DEFAULT_DELAY_FEEDBACK_END = 0.6
|
|
|
|
# Tipos de secciones y sus niveles de energía
|
|
SECTION_ENERGY_LEVELS = {
|
|
"intro": 0.2,
|
|
"build": 0.7,
|
|
"drop": 1.0,
|
|
"break": 0.3,
|
|
"break1": 0.3,
|
|
"break2": 0.4,
|
|
"drop2": 1.0,
|
|
"outro": 0.15,
|
|
"build2": 0.75,
|
|
"peak": 1.0,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CLASES DE DATOS
|
|
# =============================================================================
|
|
|
|
@dataclass
|
|
class SectionMarker:
|
|
"""Representa un marcador de sección en el arrangement."""
|
|
name: str
|
|
start_bar: int
|
|
end_bar: int
|
|
color: int = 0
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"start_bar": self.start_bar,
|
|
"end_bar": self.end_bar,
|
|
"color": self.color,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class AutomationPoint:
|
|
"""Punto de automatización (tiempo, valor)."""
|
|
time: float # En beats
|
|
value: float
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"time": self.time,
|
|
"value": self.value,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class AutomationEnvelope:
|
|
"""Envelope de automatización completo."""
|
|
parameter_name: str
|
|
device_name: str
|
|
points: List[AutomationPoint] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"parameter_name": self.parameter_name,
|
|
"device_name": self.device_name,
|
|
"points": [p.to_dict() for p in self.points],
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ArrangementClip:
|
|
"""Representa un clip en el Arrangement View."""
|
|
name: str
|
|
track_index: int
|
|
start_time: float # En beats
|
|
duration: float
|
|
is_audio: bool = False
|
|
sample_path: str = ""
|
|
notes: List[Dict[str, Any]] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"track_index": self.track_index,
|
|
"start_time": self.start_time,
|
|
"duration": self.duration,
|
|
"is_audio": self.is_audio,
|
|
"sample_path": self.sample_path,
|
|
"notes": self.notes,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ArrangementSection:
|
|
"""Sección completa del arrangement con clips y automatizaciones."""
|
|
name: str
|
|
start_bar: int
|
|
bars: int
|
|
clips: List[ArrangementClip] = field(default_factory=list)
|
|
automations: List[AutomationEnvelope] = field(default_factory=list)
|
|
energy_level: float = 0.5
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"start_bar": self.start_bar,
|
|
"bars": self.bars,
|
|
"clips": [c.to_dict() for c in self.clips],
|
|
"automations": [a.to_dict() for a in self.automations],
|
|
"energy_level": self.energy_level,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ArrangementConfig:
|
|
"""Configuración completa del arrangement."""
|
|
total_bars: int
|
|
sections: List[ArrangementSection] = field(default_factory=list)
|
|
markers: List[SectionMarker] = field(default_factory=list)
|
|
tempo: float = 95.0
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"total_bars": self.total_bars,
|
|
"sections": [s.to_dict() for s in self.sections],
|
|
"markers": [m.to_dict() for m in self.markers],
|
|
"tempo": self.tempo,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CLASE 1: ARRANGEMENT BUILDER (T021-T025)
|
|
# =============================================================================
|
|
|
|
class ArrangementBuilder:
|
|
"""
|
|
Constructor de estructuras de Arrangement View.
|
|
|
|
Crea estructuras de canción completas (Intro→Build→Drop→Break→Outro)
|
|
y gestiona la transición entre Session View y Arrangement View.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._config: Optional[ArrangementConfig] = None
|
|
self._sections: List[ArrangementSection] = []
|
|
self._markers: List[SectionMarker] = []
|
|
|
|
def build_arrangement_structure(self, song_config: Dict[str, Any]) -> ArrangementConfig:
|
|
"""
|
|
T021: Crea estructura completa Intro→Build→Drop→Break→Outro.
|
|
|
|
Args:
|
|
song_config: Configuración de canción con BPM, estructura, etc.
|
|
|
|
Returns:
|
|
ArrangementConfig con toda la estructura
|
|
"""
|
|
structure_name = song_config.get("structure", "standard")
|
|
bpm = song_config.get("bpm", 95.0)
|
|
|
|
# Obtener configuración de estructura
|
|
if structure_name in ARRANGEMENT_STRUCTURES:
|
|
structure = ARRANGEMENT_STRUCTURES[structure_name]
|
|
else:
|
|
structure = ARRANGEMENT_STRUCTURES["intro_build_drop_break_outro"]
|
|
|
|
total_bars = sum(bars for _, bars in structure)
|
|
|
|
# Crear secciones
|
|
current_bar = 0
|
|
sections = []
|
|
markers = []
|
|
|
|
for section_name, bars in structure:
|
|
energy = SECTION_ENERGY_LEVELS.get(section_name, 0.5)
|
|
|
|
section = ArrangementSection(
|
|
name=section_name,
|
|
start_bar=current_bar,
|
|
bars=bars,
|
|
energy_level=energy,
|
|
)
|
|
sections.append(section)
|
|
|
|
# Crear marcador
|
|
marker = SectionMarker(
|
|
name=section_name.upper(),
|
|
start_bar=current_bar,
|
|
end_bar=current_bar + bars,
|
|
color=self._get_section_color(section_name),
|
|
)
|
|
markers.append(marker)
|
|
|
|
current_bar += bars
|
|
|
|
config = ArrangementConfig(
|
|
total_bars=total_bars,
|
|
sections=sections,
|
|
markers=markers,
|
|
tempo=bpm,
|
|
)
|
|
|
|
self._config = config
|
|
self._sections = sections
|
|
self._markers = markers
|
|
|
|
logger.info("Estructura de arrangement creada: %d compases, %d secciones",
|
|
total_bars, len(sections))
|
|
|
|
return config
|
|
|
|
def create_section_marker(self, name: str, start_bar: int) -> SectionMarker:
|
|
"""
|
|
T022: Crea un marcador de sección.
|
|
|
|
Args:
|
|
name: Nombre del marcador
|
|
start_bar: Compás inicial
|
|
|
|
Returns:
|
|
SectionMarker creado
|
|
"""
|
|
# Detectar duración basada en nombre de sección
|
|
default_bars = {
|
|
"intro": 8, "build": 8, "drop": 16, "break": 8,
|
|
"outro": 8, "peak": 8,
|
|
}
|
|
bars = default_bars.get(name.lower(), 8)
|
|
|
|
marker = SectionMarker(
|
|
name=name.upper(),
|
|
start_bar=start_bar,
|
|
end_bar=start_bar + bars,
|
|
color=self._get_section_color(name),
|
|
)
|
|
|
|
self._markers.append(marker)
|
|
logger.info("Marcador creado: %s en compás %d", name, start_bar)
|
|
|
|
return marker
|
|
|
|
def duplicate_clips_to_arrangement(
|
|
self,
|
|
session_clips: List[Dict[str, Any]],
|
|
arrangement_positions: List[Dict[str, Any]]
|
|
) -> List[ArrangementClip]:
|
|
"""
|
|
T023: Copia clips de Session View a Arrangement View.
|
|
|
|
Args:
|
|
session_clips: Lista de clips de Session View
|
|
arrangement_positions: Posiciones donde colocar cada clip
|
|
|
|
Returns:
|
|
Lista de ArrangementClip creados
|
|
"""
|
|
arrangement_clips = []
|
|
|
|
for i, clip_info in enumerate(session_clips):
|
|
if i >= len(arrangement_positions):
|
|
break
|
|
|
|
pos = arrangement_positions[i]
|
|
|
|
arrangement_clip = ArrangementClip(
|
|
name=clip_info.get("name", f"Clip {i}"),
|
|
track_index=pos.get("track_index", clip_info.get("track_index", 0)),
|
|
start_time=pos.get("start_time", pos.get("start_bar", 0) * 4.0),
|
|
duration=clip_info.get("duration", 4.0),
|
|
is_audio=clip_info.get("is_audio", False),
|
|
sample_path=clip_info.get("sample_path", ""),
|
|
notes=clip_info.get("notes", []),
|
|
)
|
|
|
|
arrangement_clips.append(arrangement_clip)
|
|
|
|
# Añadir a la sección correspondiente
|
|
start_bar = int(arrangement_clip.start_time / 4.0)
|
|
for section in self._sections:
|
|
if section.start_bar <= start_bar < section.start_bar + section.bars:
|
|
section.clips.append(arrangement_clip)
|
|
break
|
|
|
|
logger.info("%d clips duplicados a Arrangement View", len(arrangement_clips))
|
|
return arrangement_clips
|
|
|
|
def create_arrangement_midi_clip(
|
|
self,
|
|
track_index: int,
|
|
start_time: float,
|
|
length: float,
|
|
notes: List[Dict[str, Any]]
|
|
) -> ArrangementClip:
|
|
"""
|
|
T024: Crea un clip MIDI en Arrangement View.
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_time: Tiempo de inicio en beats
|
|
length: Duración en beats
|
|
notes: Lista de notas MIDI
|
|
|
|
Returns:
|
|
ArrangementClip creado
|
|
"""
|
|
clip = ArrangementClip(
|
|
name=f"MIDI Clip - Track {track_index}",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=length,
|
|
is_audio=False,
|
|
notes=notes,
|
|
)
|
|
|
|
# Añadir a sección correspondiente
|
|
start_bar = int(start_time / 4.0)
|
|
for section in self._sections:
|
|
if section.start_bar <= start_bar < section.start_bar + section.bars:
|
|
section.clips.append(clip)
|
|
break
|
|
|
|
logger.info("Clip MIDI creado: track %d, %d notas", track_index, len(notes))
|
|
return clip
|
|
|
|
def create_arrangement_audio_clip(
|
|
self,
|
|
track_index: int,
|
|
sample_path: str,
|
|
start_time: float,
|
|
length: float
|
|
) -> ArrangementClip:
|
|
"""
|
|
T025: Crea un clip de audio en Arrangement View.
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
sample_path: Ruta al archivo de audio
|
|
start_time: Tiempo de inicio en beats
|
|
length: Duración en beats
|
|
|
|
Returns:
|
|
ArrangementClip creado
|
|
"""
|
|
clip = ArrangementClip(
|
|
name=os.path.basename(sample_path) if sample_path else "Audio Clip",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=length,
|
|
is_audio=True,
|
|
sample_path=sample_path,
|
|
)
|
|
|
|
# Añadir a sección correspondiente
|
|
start_bar = int(start_time / 4.0)
|
|
for section in self._sections:
|
|
if section.start_bar <= start_bar < section.start_bar + section.bars:
|
|
section.clips.append(clip)
|
|
break
|
|
|
|
logger.info("Clip de audio creado: track %d, %s", track_index, os.path.basename(sample_path))
|
|
return clip
|
|
|
|
def fill_arrangement_with_song(self, song_config: Dict[str, Any]) -> ArrangementConfig:
|
|
"""
|
|
Pipeline completo: crea estructura y llena con clips desde Session View.
|
|
|
|
Args:
|
|
song_config: Configuración completa de la canción
|
|
|
|
Returns:
|
|
ArrangementConfig final
|
|
"""
|
|
# 1. Crear estructura base
|
|
config = self.build_arrangement_structure(song_config)
|
|
|
|
# 2. Procesar tracks de la configuración
|
|
tracks = song_config.get("tracks", [])
|
|
|
|
for track_idx, track in enumerate(tracks):
|
|
clips = track.get("clips", [])
|
|
|
|
for clip in clips:
|
|
start_time = clip.get("start_time", 0.0)
|
|
duration = clip.get("duration", 4.0)
|
|
notes = clip.get("notes", [])
|
|
sample_path = clip.get("sample_path", "")
|
|
|
|
if sample_path:
|
|
# Es un clip de audio
|
|
self.create_arrangement_audio_clip(
|
|
track_index=track_idx,
|
|
sample_path=sample_path,
|
|
start_time=start_time,
|
|
length=duration
|
|
)
|
|
elif notes:
|
|
# Es un clip MIDI
|
|
self.create_arrangement_midi_clip(
|
|
track_index=track_idx,
|
|
start_time=start_time,
|
|
length=duration,
|
|
notes=notes
|
|
)
|
|
|
|
logger.info("Pipeline completado: arrangement lleno con %d tracks", len(tracks))
|
|
return config
|
|
|
|
def _get_section_color(self, section_name: str) -> int:
|
|
"""Retorna color para una sección según su tipo."""
|
|
colors = {
|
|
"intro": 1, # Azul
|
|
"build": 3, # Naranja
|
|
"drop": 5, # Rojo
|
|
"break": 2, # Verde
|
|
"break1": 2,
|
|
"break2": 2,
|
|
"drop2": 5,
|
|
"outro": 6, # Púrpura
|
|
"peak": 4, # Amarillo
|
|
}
|
|
return colors.get(section_name.lower(), 0)
|
|
|
|
|
|
# =============================================================================
|
|
# CLASE 2: AUTOMATION ENGINE (T026-T030)
|
|
# =============================================================================
|
|
|
|
class AutomationEngine:
|
|
"""
|
|
Motor de automatización para parámetros de devices y mezcla.
|
|
|
|
Crea envelopes de automatización para efectos comunes como
|
|
filtros, reverb, volumen, delay y envíos.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._envelopes: List[AutomationEnvelope] = []
|
|
|
|
def automate_filter(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
end_bar: int,
|
|
start_freq: float = DEFAULT_FILTER_FREQ_START,
|
|
end_freq: float = DEFAULT_FILTER_FREQ_END,
|
|
curve: str = "linear"
|
|
) -> AutomationEnvelope:
|
|
"""
|
|
T026: Automatización de cutoff de AutoFilter (sweep).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
end_bar: Compás final
|
|
start_freq: Frecuencia inicial en Hz
|
|
end_freq: Frecuencia final en Hz
|
|
curve: Tipo de curva ("linear", "exponential", "logarithmic")
|
|
|
|
Returns:
|
|
AutomationEnvelope creado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
end_time = end_bar * 4.0
|
|
duration = end_time - start_time
|
|
|
|
points = []
|
|
num_points = max(8, int(duration / 4)) # Un punto por compás mínimo
|
|
|
|
for i in range(num_points + 1):
|
|
t = i / num_points
|
|
time = start_time + t * duration
|
|
|
|
if curve == "exponential":
|
|
t = t * t
|
|
elif curve == "logarithmic":
|
|
t = math.sqrt(t)
|
|
|
|
# Interpolación logarítmica para frecuencia
|
|
freq = start_freq * ((end_freq / start_freq) ** t)
|
|
|
|
points.append(AutomationPoint(time=time, value=freq))
|
|
|
|
envelope = AutomationEnvelope(
|
|
parameter_name="Frequency",
|
|
device_name="AutoFilter",
|
|
points=points,
|
|
)
|
|
|
|
self._envelopes.append(envelope)
|
|
logger.info("AutoFilter sweep: %d->%d compases, %.0f->%.0f Hz",
|
|
start_bar, end_bar, start_freq, end_freq)
|
|
|
|
return envelope
|
|
|
|
def automate_reverb(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
end_bar: int,
|
|
dry_wet_start: float = DEFAULT_REVERB_WET_START,
|
|
dry_wet_end: float = DEFAULT_REVERB_WET_END,
|
|
parameter: str = "Dry/Wet"
|
|
) -> AutomationEnvelope:
|
|
"""
|
|
T027: Automatización de wet/dry de reverb.
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
end_bar: Compás final
|
|
dry_wet_start: Valor inicial (0.0-1.0)
|
|
dry_wet_end: Valor final (0.0-1.0)
|
|
parameter: Nombre del parámetro a automatizar
|
|
|
|
Returns:
|
|
AutomationEnvelope creado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
end_time = end_bar * 4.0
|
|
duration = end_time - start_time
|
|
|
|
points = []
|
|
num_points = max(4, int(duration / 4))
|
|
|
|
for i in range(num_points + 1):
|
|
t = i / num_points
|
|
time = start_time + t * duration
|
|
|
|
# Interpolación lineal
|
|
value = dry_wet_start + (dry_wet_end - dry_wet_start) * t
|
|
|
|
points.append(AutomationPoint(time=time, value=value))
|
|
|
|
envelope = AutomationEnvelope(
|
|
parameter_name=parameter,
|
|
device_name="Reverb",
|
|
points=points,
|
|
)
|
|
|
|
self._envelopes.append(envelope)
|
|
logger.info("Reverb automation: %d->%d compases, %.2f->%.2f",
|
|
start_bar, end_bar, dry_wet_start, dry_wet_end)
|
|
|
|
return envelope
|
|
|
|
def automate_volume(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
end_bar: int,
|
|
start_vol: float = DEFAULT_VOLUME_START,
|
|
end_vol: float = DEFAULT_VOLUME_END,
|
|
fade_type: str = "in"
|
|
) -> AutomationEnvelope:
|
|
"""
|
|
T028: Automatización de volumen (fade in/out).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
end_bar: Compás final
|
|
start_vol: Volumen inicial (0.0-1.0)
|
|
end_vol: Volumen final (0.0-1.0)
|
|
fade_type: "in", "out", o "crossfade"
|
|
|
|
Returns:
|
|
AutomationEnvelope creado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
end_time = end_bar * 4.0
|
|
duration = end_time - start_time
|
|
|
|
points = []
|
|
num_points = max(4, int(duration / 4))
|
|
|
|
for i in range(num_points + 1):
|
|
t = i / num_points
|
|
time = start_time + t * duration
|
|
|
|
# Curva de fade más natural
|
|
if fade_type == "in":
|
|
t = t * t # Curva exponencial suave
|
|
elif fade_type == "out":
|
|
t = math.sqrt(t)
|
|
|
|
value = start_vol + (end_vol - start_vol) * t
|
|
points.append(AutomationPoint(time=time, value=value))
|
|
|
|
envelope = AutomationEnvelope(
|
|
parameter_name="Volume",
|
|
device_name="Mixer",
|
|
points=points,
|
|
)
|
|
|
|
self._envelopes.append(envelope)
|
|
logger.info("Volume fade %s: %d->%d compases, %.2f->%.2f",
|
|
fade_type, start_bar, end_bar, start_vol, end_vol)
|
|
|
|
return envelope
|
|
|
|
def automate_delay(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
end_bar: int,
|
|
feedback_start: float = DEFAULT_DELAY_FEEDBACK_START,
|
|
feedback_end: float = DEFAULT_DELAY_FEEDBACK_END,
|
|
parameter: str = "Feedback"
|
|
) -> AutomationEnvelope:
|
|
"""
|
|
T029: Automatización de feedback de delay.
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
end_bar: Compás final
|
|
feedback_start: Feedback inicial (0.0-1.0)
|
|
feedback_end: Feedback final (0.0-1.0)
|
|
parameter: Nombre del parámetro
|
|
|
|
Returns:
|
|
AutomationEnvelope creado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
end_time = end_bar * 4.0
|
|
duration = end_time - start_time
|
|
|
|
points = []
|
|
num_points = max(4, int(duration / 4))
|
|
|
|
for i in range(num_points + 1):
|
|
t = i / num_points
|
|
time = start_time + t * duration
|
|
|
|
value = feedback_start + (feedback_end - feedback_start) * t
|
|
points.append(AutomationPoint(time=time, value=value))
|
|
|
|
envelope = AutomationEnvelope(
|
|
parameter_name=parameter,
|
|
device_name="Delay",
|
|
points=points,
|
|
)
|
|
|
|
self._envelopes.append(envelope)
|
|
logger.info("Delay feedback: %d->%d compases, %.2f->%.2f",
|
|
start_bar, end_bar, feedback_start, feedback_end)
|
|
|
|
return envelope
|
|
|
|
def automate_send(
|
|
self,
|
|
track_index: int,
|
|
return_index: int,
|
|
start_bar: int,
|
|
end_bar: int,
|
|
start_amount: float = 0.0,
|
|
end_amount: float = 0.5,
|
|
send_name: str = ""
|
|
) -> AutomationEnvelope:
|
|
"""
|
|
T030: Automatización de cantidad de envío (send).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
return_index: Índice del track de retorno
|
|
start_bar: Compás inicial
|
|
end_bar: Compás final
|
|
start_amount: Cantidad inicial (0.0-1.0)
|
|
end_amount: Cantidad final (0.0-1.0)
|
|
send_name: Nombre opcional del send
|
|
|
|
Returns:
|
|
AutomationEnvelope creado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
end_time = end_bar * 4.0
|
|
duration = end_time - start_time
|
|
|
|
points = []
|
|
num_points = max(4, int(duration / 4))
|
|
|
|
for i in range(num_points + 1):
|
|
t = i / num_points
|
|
time = start_time + t * duration
|
|
|
|
value = start_amount + (end_amount - start_amount) * t
|
|
points.append(AutomationPoint(time=time, value=value))
|
|
|
|
device_name = send_name if send_name else f"Send {return_index}"
|
|
|
|
envelope = AutomationEnvelope(
|
|
parameter_name="Send Amount",
|
|
device_name=device_name,
|
|
points=points,
|
|
)
|
|
|
|
self._envelopes.append(envelope)
|
|
logger.info("Send automation: %d->%d compases, %.2f->%.2f",
|
|
start_bar, end_bar, start_amount, end_amount)
|
|
|
|
return envelope
|
|
|
|
def get_all_envelopes(self) -> List[AutomationEnvelope]:
|
|
"""Retorna todos los envelopes creados."""
|
|
return self._envelopes.copy()
|
|
|
|
|
|
# =============================================================================
|
|
# CLASE 3: FX CREATOR (T031-T035)
|
|
# =============================================================================
|
|
|
|
class FXCreator:
|
|
"""
|
|
Creador de efectos FX para transiciones y énfasis.
|
|
|
|
Genera risers, downlifters, impacts y otros efectos
|
|
para mejorar las transiciones entre secciones.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._fx_clips: List[ArrangementClip] = []
|
|
|
|
def create_riser(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
duration: int = 8,
|
|
intensity: float = 0.8,
|
|
pitch_range: Tuple[int, int] = (36, 84)
|
|
) -> ArrangementClip:
|
|
"""
|
|
T031: Crea un riser pre-drop (crescendo de pitch/tensión).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
duration: Duración en compases
|
|
intensity: Intensidad (0.0-1.0)
|
|
pitch_range: Rango de notas MIDI (min, max)
|
|
|
|
Returns:
|
|
ArrangementClip del riser
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
total_duration = duration * 4.0
|
|
|
|
# Crear notas que suben de pitch
|
|
notes = []
|
|
num_notes = int(duration * 4 * 2) # 2 notas por beat
|
|
|
|
min_pitch, max_pitch = pitch_range
|
|
|
|
for i in range(num_notes):
|
|
t = i / num_notes
|
|
time = start_time + t * total_duration
|
|
|
|
# Pitch ascendente
|
|
pitch = int(min_pitch + (max_pitch - min_pitch) * t)
|
|
|
|
# Velocity ascendente para más tensión
|
|
velocity = int(60 + 67 * t * intensity)
|
|
|
|
# Duración más corta al final para staccato effect
|
|
note_duration = 0.5 - (0.3 * t)
|
|
|
|
notes.append({
|
|
"pitch": pitch,
|
|
"start_time": time,
|
|
"duration": max(0.1, note_duration),
|
|
"velocity": min(127, velocity),
|
|
})
|
|
|
|
clip = ArrangementClip(
|
|
name=f"Riser - {duration} bars",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=total_duration,
|
|
is_audio=False,
|
|
notes=notes,
|
|
)
|
|
|
|
self._fx_clips.append(clip)
|
|
logger.info("Riser creado: %d compases, intensidad %.2f", duration, intensity)
|
|
|
|
return clip
|
|
|
|
def create_downlifter(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
duration: int = 4,
|
|
intensity: float = 0.7,
|
|
pitch_range: Tuple[int, int] = (72, 36)
|
|
) -> ArrangementClip:
|
|
"""
|
|
T032: Crea un downlifter post-drop (descenso de pitch/tensión).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
duration: Duración en compases
|
|
intensity: Intensidad (0.0-1.0)
|
|
pitch_range: Rango de notas MIDI (start, end)
|
|
|
|
Returns:
|
|
ArrangementClip del downlifter
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
total_duration = duration * 4.0
|
|
|
|
notes = []
|
|
num_notes = int(duration * 4)
|
|
|
|
start_pitch, end_pitch = pitch_range
|
|
|
|
for i in range(num_notes):
|
|
t = i / num_notes
|
|
time = start_time + t * total_duration
|
|
|
|
# Pitch descendente
|
|
pitch = int(start_pitch + (end_pitch - start_pitch) * t)
|
|
|
|
# Velocity descendente
|
|
velocity = int(100 - 60 * t * intensity)
|
|
|
|
notes.append({
|
|
"pitch": pitch,
|
|
"start_time": time,
|
|
"duration": 0.5,
|
|
"velocity": max(1, velocity),
|
|
})
|
|
|
|
clip = ArrangementClip(
|
|
name=f"Downlifter - {duration} bars",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=total_duration,
|
|
is_audio=False,
|
|
notes=notes,
|
|
)
|
|
|
|
self._fx_clips.append(clip)
|
|
logger.info("Downlifter creado: %d compases, intensidad %.2f", duration, intensity)
|
|
|
|
return clip
|
|
|
|
def create_impact(
|
|
self,
|
|
track_index: int,
|
|
position: Union[int, float],
|
|
intensity: float = 1.0,
|
|
impact_type: str = "hit"
|
|
) -> ArrangementClip:
|
|
"""
|
|
T033: Crea un impact FX (hit, crash, sub drop).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
position: Posición en compases (int) o beats (float)
|
|
intensity: Intensidad del impacto (0.0-1.0)
|
|
impact_type: Tipo de impacto ("hit", "crash", "sub_drop", "noise")
|
|
|
|
Returns:
|
|
ArrangementClip del impact
|
|
"""
|
|
if isinstance(position, int):
|
|
start_time = position * 4.0
|
|
else:
|
|
start_time = position
|
|
|
|
# Configuración según tipo
|
|
if impact_type == "hit":
|
|
base_pitch = 36
|
|
velocity = int(100 + 27 * intensity)
|
|
duration = 2.0
|
|
elif impact_type == "crash":
|
|
base_pitch = 49
|
|
velocity = int(80 + 47 * intensity)
|
|
duration = 4.0
|
|
elif impact_type == "sub_drop":
|
|
base_pitch = 24
|
|
velocity = int(110 + 17 * intensity)
|
|
duration = 3.0
|
|
else: # noise
|
|
base_pitch = 60
|
|
velocity = int(90 + 37 * intensity)
|
|
duration = 2.0
|
|
|
|
notes = [{
|
|
"pitch": base_pitch,
|
|
"start_time": start_time,
|
|
"duration": duration,
|
|
"velocity": min(127, velocity),
|
|
}]
|
|
|
|
clip = ArrangementClip(
|
|
name=f"Impact {impact_type}",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=duration,
|
|
is_audio=False,
|
|
notes=notes,
|
|
)
|
|
|
|
self._fx_clips.append(clip)
|
|
logger.info("Impact creado: %s en %.2f, intensidad %.2f", impact_type, position, intensity)
|
|
|
|
return clip
|
|
|
|
def create_silence(
|
|
self,
|
|
track_index: int,
|
|
start_bar: int,
|
|
duration: int = 1,
|
|
fade_edges: bool = True
|
|
) -> ArrangementClip:
|
|
"""
|
|
T034: Crea una barra de silencio (mute momentáneo).
|
|
|
|
Args:
|
|
track_index: Índice de la pista
|
|
start_bar: Compás inicial
|
|
duration: Duración en compases
|
|
fade_edges: Si se aplican fades en los bordes
|
|
|
|
Returns:
|
|
ArrangementClip de silencio (como marcador)
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
total_duration = duration * 4.0
|
|
|
|
# El silencio se implementa como un clip vacío con metadatos
|
|
# En la práctica, esto se usa para automatizar el volumen a -inf
|
|
clip = ArrangementClip(
|
|
name=f"Silence - {duration} bars",
|
|
track_index=track_index,
|
|
start_time=start_time,
|
|
duration=total_duration,
|
|
is_audio=False,
|
|
notes=[], # Sin notas = silencio
|
|
)
|
|
|
|
self._fx_clips.append(clip)
|
|
logger.info("Silencio creado: %d compases desde compás %d", duration, start_bar)
|
|
|
|
return clip
|
|
|
|
def create_fx_automation_section(
|
|
self,
|
|
section_type: str,
|
|
start_bar: int,
|
|
duration: int,
|
|
track_indices: Optional[List[int]] = None
|
|
) -> List[ArrangementClip]:
|
|
"""
|
|
T035: Crea una sección completa de FX según el tipo.
|
|
|
|
Args:
|
|
section_type: Tipo de sección ("pre_drop", "post_drop", "transition")
|
|
start_bar: Compás inicial
|
|
duration: Duración en compases
|
|
track_indices: Lista de tracks afectados (None = todos)
|
|
|
|
Returns:
|
|
Lista de ArrangementClips de FX
|
|
"""
|
|
clips = []
|
|
|
|
if track_indices is None:
|
|
track_indices = [0, 1, 2] # Default tracks
|
|
|
|
if section_type == "pre_drop":
|
|
# Riser en build
|
|
for idx in track_indices[:1]: # Solo en primer track de FX
|
|
clip = self.create_riser(idx, start_bar, duration, intensity=0.9)
|
|
clips.append(clip)
|
|
|
|
# Impact al final
|
|
if len(track_indices) > 1:
|
|
impact = self.create_impact(
|
|
track_indices[1],
|
|
start_bar + duration,
|
|
intensity=1.0,
|
|
impact_type="hit"
|
|
)
|
|
clips.append(impact)
|
|
|
|
elif section_type == "post_drop":
|
|
# Downlifter después del drop
|
|
for idx in track_indices[:1]:
|
|
clip = self.create_downlifter(idx, start_bar, duration, intensity=0.6)
|
|
clips.append(clip)
|
|
|
|
elif section_type == "transition":
|
|
# Swell hacia arriba y luego down
|
|
half_duration = duration // 2
|
|
|
|
for idx in track_indices[:1]:
|
|
# Primera mitad: subida
|
|
rise = self.create_riser(idx, start_bar, half_duration, intensity=0.7)
|
|
clips.append(rise)
|
|
|
|
# Segunda mitad: bajada
|
|
down = self.create_downlifter(idx, start_bar + half_duration, half_duration, intensity=0.5)
|
|
clips.append(down)
|
|
|
|
logger.info("Sección FX '%s' creada: %d clips", section_type, len(clips))
|
|
return clips
|
|
|
|
def get_all_fx_clips(self) -> List[ArrangementClip]:
|
|
"""Retorna todos los clips FX creados."""
|
|
return self._fx_clips.copy()
|
|
|
|
|
|
# =============================================================================
|
|
# CLASE 4: SAMPLE PROCESSOR (T036-T040)
|
|
# =============================================================================
|
|
|
|
class SampleProcessor:
|
|
"""
|
|
Procesador avanzado de samples.
|
|
|
|
Proporciona funcionalidades para resamplear, revertir, hacer slices,
|
|
aplicar efectos granulares y crear capas ambientales.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._processed_samples: List[Dict[str, Any]] = []
|
|
|
|
def resample_track(
|
|
self,
|
|
track_index: int,
|
|
output_track_index: int,
|
|
start_bar: int = 0,
|
|
duration_bars: int = 16,
|
|
output_name: str = "Resampled"
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
T036: Graba/resamplea un track a un track de audio.
|
|
|
|
Args:
|
|
track_index: Índice del track a resamplear
|
|
output_track_index: Índice del track de salida
|
|
start_bar: Compás de inicio
|
|
duration_bars: Duración en compases
|
|
output_name: Nombre del clip resultante
|
|
|
|
Returns:
|
|
Información del sample resampleado
|
|
"""
|
|
start_time = start_bar * 4.0
|
|
duration = duration_bars * 4.0
|
|
|
|
result = {
|
|
"source_track": track_index,
|
|
"output_track": output_track_index,
|
|
"start_time": start_time,
|
|
"duration": duration,
|
|
"name": output_name,
|
|
"status": "configured",
|
|
"note": "Resampling requiere renderizado en Ableton Live",
|
|
}
|
|
|
|
self._processed_samples.append(result)
|
|
logger.info("Resample configurado: track %d -> %d (%d compases)",
|
|
track_index, output_track_index, duration_bars)
|
|
|
|
return result
|
|
|
|
def reverse_sample(
|
|
self,
|
|
sample_path: str,
|
|
output_path: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
T037: Carga un sample, lo revierte y guarda nuevo archivo.
|
|
|
|
Args:
|
|
sample_path: Ruta al sample original
|
|
output_path: Ruta de salida (None = añade _reversed)
|
|
|
|
Returns:
|
|
Información del sample revertido
|
|
"""
|
|
if not os.path.isfile(sample_path):
|
|
return {"error": f"Sample no encontrado: {sample_path}"}
|
|
|
|
# Generar nombre de salida si no se proporciona
|
|
if output_path is None:
|
|
base, ext = os.path.splitext(sample_path)
|
|
output_path = f"{base}_reversed{ext}"
|
|
|
|
result = {
|
|
"original_path": sample_path,
|
|
"output_path": output_path,
|
|
"status": "configured",
|
|
"note": "Reversing requiere procesamiento de audio externo",
|
|
}
|
|
|
|
self._processed_samples.append(result)
|
|
logger.info("Reverse configurado: %s", os.path.basename(sample_path))
|
|
|
|
return result
|
|
|
|
def slice_and_rearrange(
|
|
self,
|
|
sample_path: str,
|
|
num_slices: int = 8,
|
|
new_pattern: Optional[List[int]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
T038: Divide un sample en slices y los rearrangea.
|
|
|
|
Args:
|
|
sample_path: Ruta al sample
|
|
num_slices: Número de slices a crear
|
|
new_pattern: Patrón de rearrange (índices de slices)
|
|
|
|
Returns:
|
|
Información del sample procesado
|
|
"""
|
|
if not os.path.isfile(sample_path):
|
|
return {"error": f"Sample no encontrado: {sample_path}"}
|
|
|
|
# Si no hay patrón, crear uno aleatorio
|
|
if new_pattern is None:
|
|
new_pattern = list(range(num_slices))
|
|
random.shuffle(new_pattern)
|
|
|
|
# Calcular puntos de slice (posiciones en beats)
|
|
# Asumimos un sample de 4 compases por defecto
|
|
total_beats = 16.0
|
|
slice_duration = total_beats / num_slices
|
|
|
|
slices = []
|
|
for i in range(num_slices):
|
|
start = i * slice_duration
|
|
end = (i + 1) * slice_duration
|
|
slices.append({
|
|
"index": i,
|
|
"start_beat": start,
|
|
"end_beat": end,
|
|
"duration": slice_duration,
|
|
})
|
|
|
|
# Crear nuevo orden
|
|
rearranged = []
|
|
for idx in new_pattern:
|
|
if 0 <= idx < len(slices):
|
|
rearranged.append(slices[idx].copy())
|
|
|
|
result = {
|
|
"original_path": sample_path,
|
|
"num_slices": num_slices,
|
|
"slices": slices,
|
|
"new_pattern": new_pattern,
|
|
"rearranged": rearranged,
|
|
"status": "configured",
|
|
}
|
|
|
|
self._processed_samples.append(result)
|
|
logger.info("Slice & rearrange: %d slices, patrón %s", num_slices, new_pattern)
|
|
|
|
return result
|
|
|
|
def apply_granular_effect(
|
|
self,
|
|
track_index: int,
|
|
grain_size: float = 0.1,
|
|
density: float = 0.5,
|
|
spread: float = 0.3,
|
|
duration_bars: int = 4
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
T039: Aplica efecto granular (simulado con notas MIDI).
|
|
|
|
Args:
|
|
track_index: Índice del track
|
|
grain_size: Tamaño de grano en beats
|
|
density: Densidad de granos (0.0-1.0)
|
|
spread: Dispersión estéreo/pitch
|
|
duration_bars: Duración en compases
|
|
|
|
Returns:
|
|
Información del efecto aplicado
|
|
"""
|
|
duration = duration_bars * 4.0
|
|
|
|
# Crear notas que simulan granos
|
|
notes = []
|
|
current_time = 0.0
|
|
|
|
while current_time < duration:
|
|
# Decidir si colocar un grano
|
|
if random.random() < density:
|
|
# Pitch aleatorio con spread
|
|
base_pitch = 60
|
|
pitch_variation = int(spread * 24 * (random.random() - 0.5))
|
|
pitch = base_pitch + pitch_variation
|
|
|
|
# Velocity aleatoria
|
|
velocity = int(60 + 40 * random.random())
|
|
|
|
notes.append({
|
|
"pitch": pitch,
|
|
"start_time": current_time,
|
|
"duration": grain_size,
|
|
"velocity": velocity,
|
|
})
|
|
|
|
# Avanzar
|
|
current_time += grain_size * (0.5 + random.random() * 0.5)
|
|
|
|
result = {
|
|
"track_index": track_index,
|
|
"grain_size": grain_size,
|
|
"density": density,
|
|
"spread": spread,
|
|
"note_count": len(notes),
|
|
"notes": notes,
|
|
"status": "configured",
|
|
}
|
|
|
|
self._processed_samples.append(result)
|
|
logger.info("Granular effect: %d notas en %d compases", len(notes), duration_bars)
|
|
|
|
return result
|
|
|
|
def create_ambient_layer(
|
|
self,
|
|
chord_progression: List[str],
|
|
duration: int = 32,
|
|
base_octave: int = 4,
|
|
track_name: str = "Ambient Pad"
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
T040: Crea un track de pad ambiente con progresión armónica.
|
|
|
|
Args:
|
|
chord_progression: Lista de acordes (ej: ["Am", "F", "C", "G"])
|
|
duration: Duración total en compases
|
|
base_octave: Octava base (4 = C4)
|
|
track_name: Nombre del track
|
|
|
|
Returns:
|
|
Configuración del pad ambiente
|
|
"""
|
|
# Mapeo de acordes a notas MIDI
|
|
chord_notes = {
|
|
"Am": [9, 12, 16], # A, C, E
|
|
"Dm": [2, 5, 9], # D, F, A
|
|
"Em": [4, 7, 11], # E, G, B
|
|
"F": [5, 9, 12], # F, A, C
|
|
"G": [7, 11, 14], # G, B, D
|
|
"C": [0, 4, 7], # C, E, G
|
|
"D": [2, 6, 9], # D, F#, A
|
|
"E": [4, 8, 11], # E, G#, B
|
|
"A": [9, 13, 16], # A, C#, E
|
|
"Bm": [11, 14, 18], # B, D, F#
|
|
}
|
|
|
|
base_midi = 12 * (base_octave + 1) # C4 = 60
|
|
|
|
# Calcular compases por acorde
|
|
bars_per_chord = duration // len(chord_progression)
|
|
|
|
notes = []
|
|
current_bar = 0
|
|
|
|
for chord in chord_progression:
|
|
intervals = chord_notes.get(chord, [0, 4, 7])
|
|
|
|
# Crear notas del acorde extendidas
|
|
for bar in range(bars_per_chord):
|
|
for beat in range(4):
|
|
# Notas largas para efecto pad
|
|
if beat == 0 or random.random() < 0.3:
|
|
for interval in intervals:
|
|
pitch = base_midi + interval
|
|
# Añadir variación de octava
|
|
if random.random() < 0.2:
|
|
pitch += 12
|
|
|
|
note_time = (current_bar + bar) * 4.0 + beat
|
|
|
|
notes.append({
|
|
"pitch": pitch,
|
|
"start_time": note_time,
|
|
"duration": 2.0 + random.random() * 2.0,
|
|
"velocity": int(50 + 30 * random.random()),
|
|
})
|
|
|
|
current_bar += bars_per_chord
|
|
|
|
result = {
|
|
"track_name": track_name,
|
|
"chord_progression": chord_progression,
|
|
"duration": duration,
|
|
"note_count": len(notes),
|
|
"notes": notes,
|
|
"status": "configured",
|
|
}
|
|
|
|
self._processed_samples.append(result)
|
|
logger.info("Ambient pad creado: %d notas, progresión %s", len(notes), chord_progression)
|
|
|
|
return result
|
|
|
|
def get_all_processed(self) -> List[Dict[str, Any]]:
|
|
"""Retorna todos los samples procesados."""
|
|
return self._processed_samples.copy()
|
|
|
|
|
|
# =============================================================================
|
|
# FUNCIONES DE UTILIDAD
|
|
# =============================================================================
|
|
|
|
def arrangement_to_dict(arrangement: ArrangementConfig) -> Dict[str, Any]:
|
|
"""
|
|
Serializa un ArrangementConfig a diccionario.
|
|
|
|
Args:
|
|
arrangement: Configuración a serializar
|
|
|
|
Returns:
|
|
Diccionario con la estructura completa
|
|
"""
|
|
return arrangement.to_dict()
|
|
|
|
|
|
def dict_to_arrangement(data: Dict[str, Any]) -> ArrangementConfig:
|
|
"""
|
|
Deserializa un diccionario a ArrangementConfig.
|
|
|
|
Args:
|
|
data: Diccionario con la configuración
|
|
|
|
Returns:
|
|
ArrangementConfig reconstruido
|
|
"""
|
|
sections = []
|
|
for sec_data in data.get("sections", []):
|
|
clips = []
|
|
for clip_data in sec_data.get("clips", []):
|
|
clips.append(ArrangementClip(
|
|
name=clip_data.get("name", ""),
|
|
track_index=clip_data.get("track_index", 0),
|
|
start_time=clip_data.get("start_time", 0.0),
|
|
duration=clip_data.get("duration", 4.0),
|
|
is_audio=clip_data.get("is_audio", False),
|
|
sample_path=clip_data.get("sample_path", ""),
|
|
notes=clip_data.get("notes", []),
|
|
))
|
|
|
|
automations = []
|
|
for auto_data in sec_data.get("automations", []):
|
|
points = [
|
|
AutomationPoint(time=p["time"], value=p["value"])
|
|
for p in auto_data.get("points", [])
|
|
]
|
|
automations.append(AutomationEnvelope(
|
|
parameter_name=auto_data.get("parameter_name", ""),
|
|
device_name=auto_data.get("device_name", ""),
|
|
points=points,
|
|
))
|
|
|
|
sections.append(ArrangementSection(
|
|
name=sec_data.get("name", ""),
|
|
start_bar=sec_data.get("start_bar", 0),
|
|
bars=sec_data.get("bars", 8),
|
|
clips=clips,
|
|
automations=automations,
|
|
energy_level=sec_data.get("energy_level", 0.5),
|
|
))
|
|
|
|
markers = [
|
|
SectionMarker(
|
|
name=m.get("name", ""),
|
|
start_bar=m.get("start_bar", 0),
|
|
end_bar=m.get("end_bar", 8),
|
|
color=m.get("color", 0),
|
|
)
|
|
for m in data.get("markers", [])
|
|
]
|
|
|
|
return ArrangementConfig(
|
|
total_bars=data.get("total_bars", 64),
|
|
sections=sections,
|
|
markers=markers,
|
|
tempo=data.get("tempo", 95.0),
|
|
)
|
|
|
|
|
|
def get_arrangement_length(arrangement: ArrangementConfig) -> int:
|
|
"""
|
|
Retorna la duración total del arrangement en compases.
|
|
|
|
Args:
|
|
arrangement: Configuración del arrangement
|
|
|
|
Returns:
|
|
Duración total en compases
|
|
"""
|
|
if arrangement.sections:
|
|
last_section = arrangement.sections[-1]
|
|
return last_section.start_bar + last_section.bars
|
|
return arrangement.total_bars
|
|
|
|
|
|
# =============================================================================
|
|
# FUNCIONES DE CONVENIENCIA
|
|
# =============================================================================
|
|
|
|
def create_full_arrangement(
|
|
song_config: Dict[str, Any],
|
|
include_fx: bool = True,
|
|
include_automation: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Crea un arrangement completo con todas las características.
|
|
|
|
Args:
|
|
song_config: Configuración de la canción
|
|
include_fx: Si incluir efectos FX
|
|
include_automation: Si incluir automatizaciones
|
|
|
|
Returns:
|
|
Configuración completa del arrangement
|
|
"""
|
|
# 1. Crear estructura base
|
|
builder = ArrangementBuilder()
|
|
arrangement = builder.fill_arrangement_with_song(song_config)
|
|
|
|
# 2. Añadir FX si se solicita
|
|
fx_clips = []
|
|
if include_fx:
|
|
fx_creator = FXCreator()
|
|
|
|
# Buscar secciones build y crear risers
|
|
for section in arrangement.sections:
|
|
if "build" in section.name.lower():
|
|
fx_clips.extend(
|
|
fx_creator.create_fx_automation_section(
|
|
"pre_drop",
|
|
section.start_bar,
|
|
section.bars,
|
|
[len(arrangement.sections)] # Track de FX
|
|
)
|
|
)
|
|
elif "break" in section.name.lower():
|
|
fx_clips.extend(
|
|
fx_creator.create_fx_automation_section(
|
|
"post_drop",
|
|
section.start_bar,
|
|
min(4, section.bars),
|
|
[len(arrangement.sections)]
|
|
)
|
|
)
|
|
|
|
# 3. Añadir automatizaciones si se solicita
|
|
automations = []
|
|
if include_automation:
|
|
auto_engine = AutomationEngine()
|
|
|
|
# Automatizar filtros en builds
|
|
for section in arrangement.sections:
|
|
if "build" in section.name.lower():
|
|
auto_engine.automate_filter(
|
|
track_index=5, # Bass track típico
|
|
start_bar=section.start_bar,
|
|
end_bar=section.start_bar + section.bars,
|
|
start_freq=400,
|
|
end_freq=8000,
|
|
)
|
|
|
|
return {
|
|
"arrangement": arrangement.to_dict(),
|
|
"fx_clips": [c.to_dict() for c in fx_clips],
|
|
"automations": [a.to_dict() for a in automations],
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# EXPORTS
|
|
# =============================================================================
|
|
|
|
__all__ = [
|
|
"ArrangementBuilder",
|
|
"AutomationEngine",
|
|
"FXCreator",
|
|
"SampleProcessor",
|
|
"ArrangementConfig",
|
|
"ArrangementSection",
|
|
"ArrangementClip",
|
|
"AutomationEnvelope",
|
|
"SectionMarker",
|
|
"arrangement_to_dict",
|
|
"dict_to_arrangement",
|
|
"get_arrangement_length",
|
|
"create_full_arrangement",
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# MAIN / TEST
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
print("=" * 70)
|
|
print("ARRANGEMENT ENGINE - Arrangement View and Automation Engine")
|
|
print("=" * 70)
|
|
|
|
# Test 1: ArrangementBuilder
|
|
print("\n1. Testing ArrangementBuilder...")
|
|
builder = ArrangementBuilder()
|
|
|
|
song_config = {
|
|
"bpm": 95,
|
|
"structure": "intro_build_drop_break_outro",
|
|
"tracks": [
|
|
{
|
|
"name": "Kick",
|
|
"clips": [
|
|
{"name": "Kick Pattern", "start_time": 0, "duration": 64, "notes": []}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
arrangement = builder.fill_arrangement_with_song(song_config)
|
|
print(f" Total bars: {arrangement.total_bars}")
|
|
print(f" Sections: {[s.name for s in arrangement.sections]}")
|
|
print(f" Markers: {[m.name for m in arrangement.markers]}")
|
|
|
|
# Test 2: AutomationEngine
|
|
print("\n2. Testing AutomationEngine...")
|
|
auto = AutomationEngine()
|
|
|
|
env = auto.automate_filter(
|
|
track_index=0,
|
|
start_bar=8,
|
|
end_bar=16,
|
|
start_freq=200,
|
|
end_freq=20000,
|
|
curve="exponential"
|
|
)
|
|
print(f" Filter sweep: {len(env.points)} points")
|
|
|
|
env2 = auto.automate_volume(
|
|
track_index=0,
|
|
start_bar=0,
|
|
end_bar=8,
|
|
start_vol=0.0,
|
|
end_vol=0.85,
|
|
fade_type="in"
|
|
)
|
|
print(f" Volume fade: {len(env2.points)} points")
|
|
|
|
# Test 3: FXCreator
|
|
print("\n3. Testing FXCreator...")
|
|
fx = FXCreator()
|
|
|
|
riser = fx.create_riser(track_index=7, start_bar=8, duration=8, intensity=0.9)
|
|
print(f" Riser: {len(riser.notes)} notes")
|
|
|
|
impact = fx.create_impact(track_index=7, position=16, intensity=1.0)
|
|
print(f" Impact: note pitch {impact.notes[0]['pitch']}")
|
|
|
|
fx_section = fx.create_fx_automation_section(
|
|
section_type="pre_drop",
|
|
start_bar=24,
|
|
duration=8,
|
|
track_indices=[7, 8]
|
|
)
|
|
print(f" FX Section: {len(fx_section)} clips")
|
|
|
|
# Test 4: SampleProcessor
|
|
print("\n4. Testing SampleProcessor...")
|
|
processor = SampleProcessor()
|
|
|
|
ambient = processor.create_ambient_layer(
|
|
chord_progression=["Am", "F", "C", "G"],
|
|
duration=32,
|
|
base_octave=4
|
|
)
|
|
print(f" Ambient pad: {ambient['note_count']} notes")
|
|
|
|
granular = processor.apply_granular_effect(
|
|
track_index=5,
|
|
grain_size=0.1,
|
|
density=0.6,
|
|
spread=0.4,
|
|
duration_bars=4
|
|
)
|
|
print(f" Granular effect: {granular['note_count']} grains")
|
|
|
|
slice_result = processor.slice_and_rearrange(
|
|
sample_path="C:/samples/test.wav",
|
|
num_slices=8,
|
|
new_pattern=[3, 1, 7, 0, 2, 5, 4, 6]
|
|
)
|
|
print(f" Slices: {slice_result['num_slices']}, pattern: {slice_result['new_pattern']}")
|
|
|
|
# Test 5: Utilities
|
|
print("\n5. Testing utilities...")
|
|
data = arrangement_to_dict(arrangement)
|
|
print(f" Serialized: {len(data.keys())} keys")
|
|
|
|
restored = dict_to_arrangement(data)
|
|
print(f" Restored: {len(restored.sections)} sections")
|
|
|
|
length = get_arrangement_length(arrangement)
|
|
print(f" Total length: {length} bars")
|
|
|
|
# Test 6: Full pipeline
|
|
print("\n6. Testing full arrangement pipeline...")
|
|
full = create_full_arrangement(song_config, include_fx=True, include_automation=True)
|
|
print(f" Full arrangement keys: {list(full.keys())}")
|
|
print(f" FX clips: {len(full['fx_clips'])}")
|
|
|
|
print("\n" + "=" * 70)
|
|
print("All tests completed successfully!")
|
|
print("=" * 70)
|