Files
ableton-mcp-ai/mcp_server/engines/arrangement_engine.py
OpenCode Agent 5ce8187c65 feat: Implement senior audio injection with 5 fallback methods
- 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
2026-04-12 14:02:32 -03:00

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)