- 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
1150 lines
45 KiB
Python
1150 lines
45 KiB
Python
"""
|
|
AbletonLiveBridge - Bridge between MCP server and Ableton Live API.
|
|
|
|
Provides a high-level interface for executing engine configurations
|
|
and controlling Live via the TCP connection.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import logging
|
|
from typing import Dict, List, Any, Optional, Tuple, Union
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
# Setup logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("AbletonLiveBridge")
|
|
|
|
|
|
class LiveAPIError(Exception):
|
|
"""Exception raised for Live API errors."""
|
|
pass
|
|
|
|
|
|
class DeviceNotFoundError(LiveAPIError):
|
|
"""Exception raised when a device is not found."""
|
|
pass
|
|
|
|
|
|
class TrackNotFoundError(LiveAPIError):
|
|
"""Exception raised when a track is not found."""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class MixConfiguration:
|
|
"""Configuration for mix settings."""
|
|
track_index: int
|
|
volume: Optional[float] = None
|
|
pan: Optional[float] = None
|
|
mute: Optional[bool] = None
|
|
solo: Optional[bool] = None
|
|
sends: Optional[Dict[int, float]] = None
|
|
devices: Optional[List[Dict[str, Any]]] = None
|
|
|
|
|
|
@dataclass
|
|
class CompressorSettings:
|
|
"""Settings for Ableton's Compressor device."""
|
|
threshold: float = -20.0
|
|
ratio: float = 4.0
|
|
attack: float = 0.1
|
|
release: float = 10.0
|
|
make_up: float = 0.0
|
|
use_sidechain: bool = False
|
|
|
|
|
|
@dataclass
|
|
class EQPreset:
|
|
"""EQ Eight preset configuration."""
|
|
name: str
|
|
high_pass: Optional[float] = None
|
|
low_shelf: Optional[Tuple[float, float]] = None # (freq, gain)
|
|
mid_boost: Optional[Tuple[float, float, float]] = None # (freq, gain, q)
|
|
high_shelf: Optional[Tuple[float, float]] = None # (freq, gain)
|
|
|
|
|
|
class AbletonLiveBridge:
|
|
"""
|
|
Bridge class for executing engine configurations in Ableton Live.
|
|
|
|
This class provides a high-level interface for controlling Live's
|
|
tracks, devices, arrangement, and playback via the MCP TCP connection.
|
|
"""
|
|
|
|
def __init__(self, song, mcp_connection):
|
|
"""
|
|
Initialize the Live bridge.
|
|
|
|
Args:
|
|
song: Ableton Live song object (Live.Song.Song)
|
|
mcp_connection: MCP TCP connection for sending commands
|
|
"""
|
|
self.song = song
|
|
self.mcp_connection = mcp_connection
|
|
self.live_version = self._get_live_version()
|
|
self._pending_tasks = []
|
|
|
|
logger.info(f"AbletonLiveBridge initialized (Live version: {self.live_version})")
|
|
|
|
def _get_live_version(self) -> str:
|
|
"""Get Ableton Live version for compatibility checks."""
|
|
try:
|
|
app = self.song.application()
|
|
return app.get_major_version() if hasattr(app, 'get_major_version') else "unknown"
|
|
except:
|
|
return "unknown"
|
|
|
|
def _check_api_version(self, min_version: str = "11") -> bool:
|
|
"""Check if Live API version meets minimum requirements."""
|
|
try:
|
|
if self.live_version == "unknown":
|
|
return True # Assume compatible if version unknown
|
|
return int(self.live_version) >= int(min_version)
|
|
except:
|
|
return False
|
|
|
|
def _send_tcp_command(self, command: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Send a command via TCP connection.
|
|
|
|
Args:
|
|
command: Dictionary with command data
|
|
|
|
Returns:
|
|
Response dictionary with status and result
|
|
"""
|
|
try:
|
|
if self.mcp_connection:
|
|
# Send command through MCP connection
|
|
self.mcp_connection.send(json.dumps(command).encode())
|
|
response = self.mcp_connection.recv(4096).decode()
|
|
return json.loads(response)
|
|
else:
|
|
return {"status": "error", "message": "No MCP connection available"}
|
|
except Exception as e:
|
|
logger.error(f"TCP command failed: {e}")
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
def _create_result(self, success: bool, message: str = "", data: Any = None) -> Dict[str, Any]:
|
|
"""Create a standardized result dictionary."""
|
|
result = {
|
|
"success": success,
|
|
"message": message,
|
|
"data": data
|
|
}
|
|
if not success:
|
|
logger.warning(f"Operation failed: {message}")
|
|
return result
|
|
|
|
# =========================================================================
|
|
# Bus and Return Management
|
|
# =========================================================================
|
|
|
|
def create_bus_track(self, name: str, bus_type: str = "Group") -> Dict[str, Any]:
|
|
"""
|
|
Create a group/bus track for mixing.
|
|
|
|
Args:
|
|
name: Name for the bus track
|
|
bus_type: Type of bus ("Group", "Master", etc.)
|
|
|
|
Returns:
|
|
Result dictionary with track index if successful
|
|
"""
|
|
try:
|
|
# Create group track via Live API
|
|
tracks = list(self.song.tracks)
|
|
|
|
# Create audio track first, then convert to group
|
|
self.song.create_audio_track(-1)
|
|
new_track = self.song.tracks[-1]
|
|
|
|
# Convert to group track if possible
|
|
if hasattr(new_track, 'is_grouped'):
|
|
# Set as group track
|
|
new_track.name = name
|
|
track_index = len(self.song.tracks) - 1
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Bus track '{name}' created at index {track_index}",
|
|
{"track_index": track_index, "name": name, "type": bus_type}
|
|
)
|
|
else:
|
|
# Fallback: just use as regular track
|
|
new_track.name = name
|
|
track_index = len(self.song.tracks) - 1
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Track '{name}' created at index {track_index} (group features may be limited)",
|
|
{"track_index": track_index, "name": name}
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to create bus track: {str(e)}")
|
|
|
|
def create_return_track(self, name: str, effect_type: str = "Reverb") -> Dict[str, Any]:
|
|
"""
|
|
Create a return track with an effect.
|
|
|
|
Args:
|
|
name: Name for the return track
|
|
effect_type: Type of effect ("Reverb", "Delay", etc.)
|
|
|
|
Returns:
|
|
Result dictionary with return track index if successful
|
|
"""
|
|
try:
|
|
# Create return track
|
|
if hasattr(self.song, 'create_return_track'):
|
|
self.song.create_return_track()
|
|
return_track = self.song.return_tracks[-1]
|
|
return_track.name = name
|
|
return_index = len(self.song.return_tracks) - 1
|
|
|
|
# Add effect device if possible
|
|
if effect_type and hasattr(return_track, 'devices'):
|
|
# Effect will be added by insert_device later
|
|
pass
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Return track '{name}' created at index {return_index}",
|
|
{"return_index": return_index, "name": name, "effect_type": effect_type}
|
|
)
|
|
else:
|
|
return self._create_result(False, "Live version doesn't support return tracks")
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to create return track: {str(e)}")
|
|
|
|
def route_track_to_bus(self, track_index: int, bus_name: str) -> Dict[str, Any]:
|
|
"""
|
|
Route a track's output to a bus/group track.
|
|
|
|
Args:
|
|
track_index: Index of the source track
|
|
bus_name: Name of the target bus track
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
source_track = self.song.tracks[track_index]
|
|
|
|
# Find bus track by name
|
|
bus_track = None
|
|
bus_index = -1
|
|
for i, track in enumerate(self.song.tracks):
|
|
if track.name == bus_name:
|
|
bus_track = track
|
|
bus_index = i
|
|
break
|
|
|
|
if bus_track is None:
|
|
return self._create_result(False, f"Bus track '{bus_name}' not found")
|
|
|
|
# Set output routing
|
|
if hasattr(source_track, 'output_meter_level'):
|
|
# Try to set output to the bus
|
|
# Note: Exact API may vary by Live version
|
|
if hasattr(source_track, 'output_routing_type'):
|
|
# Set routing
|
|
source_track.output_routing_type = bus_track
|
|
elif hasattr(source_track, 'group_track'):
|
|
source_track.group_track = bus_track
|
|
else:
|
|
# Manual grouping via Live's internal API
|
|
pass
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Track {track_index} routed to bus '{bus_name}' (index {bus_index})"
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to route track: {str(e)}")
|
|
|
|
def set_track_send(self, track_index: int, return_index: int, amount: float) -> Dict[str, Any]:
|
|
"""
|
|
Configure send amount from a track to a return track.
|
|
|
|
Args:
|
|
track_index: Index of the source track
|
|
return_index: Index of the return track
|
|
amount: Send amount (0.0 - 1.0)
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Clamp amount to valid range
|
|
amount = max(0.0, min(1.0, amount))
|
|
|
|
# Set send value
|
|
if hasattr(track, 'mixer_device') and hasattr(track.mixer_device, 'sends'):
|
|
sends = track.mixer_device.sends
|
|
if return_index < len(sends):
|
|
sends[return_index].value = amount
|
|
return self._create_result(
|
|
True,
|
|
f"Send {return_index} on track {track_index} set to {amount:.2f}"
|
|
)
|
|
else:
|
|
return self._create_result(False, f"Return index {return_index} out of range")
|
|
else:
|
|
return self._create_result(False, "Track doesn't support sends")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set send: {str(e)}")
|
|
|
|
# =========================================================================
|
|
# Device Management
|
|
# =========================================================================
|
|
|
|
def insert_device(self, track_index: int, device_name: str) -> Dict[str, Any]:
|
|
"""
|
|
Insert a device/instrument on a track.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
device_name: Name of the device to insert
|
|
|
|
Returns:
|
|
Result dictionary with device index if successful
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Map common device names to Live device types
|
|
device_map = {
|
|
"eq eight": "EQ Eight",
|
|
"eq8": "EQ Eight",
|
|
"compressor": "Compressor",
|
|
"reverb": "Reverb",
|
|
"delay": "Delay",
|
|
"saturator": "Saturator",
|
|
"limiter": "Limiter",
|
|
"utility": "Utility",
|
|
"filter": "Auto Filter",
|
|
"autofilter": "Auto Filter"
|
|
}
|
|
|
|
canonical_name = device_map.get(device_name.lower(), device_name)
|
|
|
|
# Try to load device from browser
|
|
if hasattr(self.song, 'browser'):
|
|
browser = self.song.browser
|
|
# Search for device
|
|
device_to_load = None
|
|
|
|
# Look in audio effects
|
|
if hasattr(browser, 'audio_effects'):
|
|
for device in browser.audio_effects:
|
|
if canonical_name.lower() in device.name.lower():
|
|
device_to_load = device
|
|
break
|
|
|
|
# Look in instruments
|
|
if device_to_load is None and hasattr(browser, 'instruments'):
|
|
for device in browser.instruments:
|
|
if canonical_name.lower() in device.name.lower():
|
|
device_to_load = device
|
|
break
|
|
|
|
# Load the device
|
|
if device_to_load and hasattr(track, 'devices'):
|
|
# Add to end of device chain
|
|
track.load_device(device_to_load)
|
|
device_index = len(track.devices) - 1
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Device '{canonical_name}' inserted on track {track_index} at position {device_index}",
|
|
{"track_index": track_index, "device_index": device_index, "device_name": canonical_name}
|
|
)
|
|
else:
|
|
return self._create_result(False, f"Device '{device_name}' not found in browser")
|
|
else:
|
|
return self._create_result(False, "Browser not available")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to insert device: {str(e)}")
|
|
|
|
def configure_device(self, track_index: int, device_name: str,
|
|
params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Configure parameters of a device on a track.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
device_name: Name of the device to configure
|
|
params: Dictionary of parameter names and values
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Find device by name
|
|
target_device = None
|
|
if hasattr(track, 'devices'):
|
|
for device in track.devices:
|
|
if device_name.lower() in device.name.lower():
|
|
target_device = device
|
|
break
|
|
|
|
if target_device is None:
|
|
raise DeviceNotFoundError(f"Device '{device_name}' not found on track {track_index}")
|
|
|
|
# Configure parameters
|
|
configured = []
|
|
failed = []
|
|
|
|
if hasattr(target_device, 'parameters'):
|
|
for param_name, param_value in params.items():
|
|
param_found = False
|
|
for param in target_device.parameters:
|
|
if param_name.lower() in param.name.lower():
|
|
try:
|
|
# Clamp value to parameter's min/max
|
|
min_val = param.min if hasattr(param, 'min') else 0
|
|
max_val = param.max if hasattr(param, 'max') else 1
|
|
clamped_value = max(min_val, min(max_val, param_value))
|
|
param.value = clamped_value
|
|
configured.append(f"{param.name} = {clamped_value}")
|
|
param_found = True
|
|
break
|
|
except Exception as pe:
|
|
failed.append(f"{param_name}: {str(pe)}")
|
|
|
|
if not param_found:
|
|
failed.append(f"{param_name}: parameter not found")
|
|
|
|
return self._create_result(
|
|
len(failed) == 0,
|
|
f"Configured {len(configured)} parameters on '{device_name}'",
|
|
{"configured": configured, "failed": failed}
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except DeviceNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to configure device: {str(e)}")
|
|
|
|
def remove_device(self, track_index: int, device_name: str) -> Dict[str, Any]:
|
|
"""
|
|
Remove a device from a track.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
device_name: Name of the device to remove
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Find and delete device
|
|
if hasattr(track, 'devices'):
|
|
for i, device in enumerate(track.devices):
|
|
if device_name.lower() in device.name.lower():
|
|
# Delete the device
|
|
track.delete_device(i)
|
|
return self._create_result(
|
|
True,
|
|
f"Device '{device_name}' removed from track {track_index}"
|
|
)
|
|
|
|
return self._create_result(False, f"Device '{device_name}' not found on track {track_index}")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to remove device: {str(e)}")
|
|
|
|
# =========================================================================
|
|
# Mix Configuration Execution
|
|
# =========================================================================
|
|
|
|
def execute_mix_config(self, config: MixConfiguration) -> Dict[str, Any]:
|
|
"""
|
|
Apply a complete mix configuration to a track.
|
|
|
|
Args:
|
|
config: MixConfiguration object with settings
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
results = []
|
|
|
|
# Apply volume
|
|
if config.volume is not None:
|
|
result = self.set_track_volume(config.track_index, config.volume)
|
|
results.append("volume" if result["success"] else f"volume: {result['message']}")
|
|
|
|
# Apply pan
|
|
if config.pan is not None:
|
|
result = self.set_track_pan(config.track_index, config.pan)
|
|
results.append("pan" if result["success"] else f"pan: {result['message']}")
|
|
|
|
# Apply mute
|
|
if config.mute is not None:
|
|
result = self._set_track_mute_internal(config.track_index, config.mute)
|
|
results.append("mute" if result["success"] else f"mute: {result['message']}")
|
|
|
|
# Apply solo
|
|
if config.solo is not None:
|
|
result = self._set_track_solo_internal(config.track_index, config.solo)
|
|
results.append("solo" if result["success"] else f"solo: {result['message']}")
|
|
|
|
# Apply sends
|
|
if config.sends:
|
|
for return_index, amount in config.sends.items():
|
|
result = self.set_track_send(config.track_index, return_index, amount)
|
|
results.append(f"send_{return_index}" if result["success"] else f"send_{return_index}: {result['message']}")
|
|
|
|
# Apply devices
|
|
if config.devices:
|
|
for device_config in config.devices:
|
|
device_name = device_config.get("name", "")
|
|
device_params = device_config.get("params", {})
|
|
|
|
# Insert device
|
|
insert_result = self.insert_device(config.track_index, device_name)
|
|
if insert_result["success"]:
|
|
# Configure device
|
|
configure_result = self.configure_device(
|
|
config.track_index, device_name, device_params
|
|
)
|
|
results.append(f"device_{device_name}" if configure_result["success"]
|
|
else f"device_{device_name}: {configure_result['message']}")
|
|
else:
|
|
results.append(f"device_{device_name}: {insert_result['message']}")
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Mix config applied to track {config.track_index}",
|
|
{"applied": results}
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to execute mix config: {str(e)}")
|
|
|
|
def apply_eq_preset(self, track_index: int, preset_name: str) -> Dict[str, Any]:
|
|
"""
|
|
Apply an EQ Eight preset to a track.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
preset_name: Name of the EQ preset to apply
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
# Define preset configurations
|
|
presets = {
|
|
"low_cut": {"hpf": 80, "ls_gain": 0},
|
|
"vocal_boost": {"hpf": 100, "mid_freq": 2500, "mid_gain": 3, "mid_q": 0.7},
|
|
"bass_enhance": {"ls_freq": 120, "ls_gain": 4, "hs_gain": -2},
|
|
"bright": {"hs_freq": 8000, "hs_gain": 3},
|
|
"scooped": {"ls_gain": -2, "mid_freq": 1000, "mid_gain": -3, "hs_gain": 2}
|
|
}
|
|
|
|
preset = presets.get(preset_name.lower(), {})
|
|
|
|
# Insert EQ Eight
|
|
insert_result = self.insert_device(track_index, "EQ Eight")
|
|
if not insert_result["success"]:
|
|
return insert_result
|
|
|
|
# Configure EQ parameters
|
|
eq_params = {}
|
|
|
|
if "hpf" in preset:
|
|
eq_params["highpass"] = preset["hpf"]
|
|
if "ls_freq" in preset:
|
|
eq_params["lowshelf freq"] = preset["ls_freq"]
|
|
if "ls_gain" in preset:
|
|
eq_params["lowshelf gain"] = preset["ls_gain"]
|
|
if "mid_freq" in preset:
|
|
eq_params["mid freq"] = preset["mid_freq"]
|
|
if "mid_gain" in preset:
|
|
eq_params["mid gain"] = preset["mid_gain"]
|
|
if "hs_freq" in preset:
|
|
eq_params["highshelf freq"] = preset["hs_freq"]
|
|
if "hs_gain" in preset:
|
|
eq_params["highshelf gain"] = preset["hs_gain"]
|
|
|
|
config_result = self.configure_device(track_index, "EQ Eight", eq_params)
|
|
|
|
return self._create_result(
|
|
config_result["success"],
|
|
f"EQ preset '{preset_name}' applied to track {track_index}",
|
|
config_result.get("data")
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to apply EQ preset: {str(e)}")
|
|
|
|
def apply_compression(self, track_index: int, settings: CompressorSettings) -> Dict[str, Any]:
|
|
"""
|
|
Apply compressor settings to a track.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
settings: CompressorSettings object
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
# Insert Compressor
|
|
insert_result = self.insert_device(track_index, "Compressor")
|
|
if not insert_result["success"]:
|
|
return insert_result
|
|
|
|
# Configure compressor parameters
|
|
comp_params = {
|
|
"threshold": settings.threshold,
|
|
"ratio": settings.ratio,
|
|
"attack": settings.attack,
|
|
"release": settings.release,
|
|
"makeup": settings.make_up
|
|
}
|
|
|
|
config_result = self.configure_device(track_index, "Compressor", comp_params)
|
|
|
|
return self._create_result(
|
|
config_result["success"],
|
|
f"Compression applied to track {track_index}",
|
|
{"settings": settings.__dict__}
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to apply compression: {str(e)}")
|
|
|
|
def setup_sidechain(self, source_track: int, target_track: int,
|
|
amount: float = 0.5) -> Dict[str, Any]:
|
|
"""
|
|
Setup sidechain compression from source to target track.
|
|
|
|
Args:
|
|
source_track: Index of the trigger/source track (e.g., kick)
|
|
target_track: Index of the track to duck (e.g., bass)
|
|
amount: Sidechain amount (0.0 - 1.0)
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
# Validate track indices
|
|
if source_track < 0 or source_track >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Source track index {source_track} out of range")
|
|
if target_track < 0 or target_track >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Target track index {target_track} out of range")
|
|
|
|
# Insert compressor on target track if not present
|
|
target = self.song.tracks[target_track]
|
|
has_compressor = False
|
|
compressor_device = None
|
|
|
|
if hasattr(target, 'devices'):
|
|
for device in target.devices:
|
|
if "compressor" in device.name.lower():
|
|
has_compressor = True
|
|
compressor_device = device
|
|
break
|
|
|
|
if not has_compressor:
|
|
insert_result = self.insert_device(target_track, "Compressor")
|
|
if not insert_result["success"]:
|
|
return insert_result
|
|
# Get the newly inserted compressor
|
|
if hasattr(target, 'devices'):
|
|
compressor_device = target.devices[-1]
|
|
|
|
# Configure sidechain routing
|
|
if compressor_device and hasattr(compressor_device, 'parameters'):
|
|
for param in compressor_device.parameters:
|
|
if "sidechain" in param.name.lower():
|
|
# Enable sidechain
|
|
param.value = 1 # or appropriate value for on
|
|
elif "sidechain source" in param.name.lower() or "input" in param.name.lower():
|
|
# Set sidechain input to source track
|
|
# This is Live-version dependent
|
|
pass
|
|
|
|
# Set threshold and ratio for ducking effect
|
|
sidechain_params = {
|
|
"threshold": -20.0,
|
|
"ratio": 4.0,
|
|
"attack": 0.01,
|
|
"release": 0.1
|
|
}
|
|
|
|
config_result = self.configure_device(target_track, "Compressor", sidechain_params)
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Sidechain setup from track {source_track} to track {target_track} (amount: {amount})"
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to setup sidechain: {str(e)}")
|
|
|
|
# =========================================================================
|
|
# Arrangement Operations
|
|
# =========================================================================
|
|
|
|
def insert_arrangement_clip(self, track_index: int, file_path: str,
|
|
start_bar: float, duration: float) -> Dict[str, Any]:
|
|
"""
|
|
Insert an audio clip into the arrangement.
|
|
|
|
Args:
|
|
track_index: Index of the target audio track
|
|
file_path: Path to the audio file
|
|
start_bar: Start position in bars
|
|
duration: Duration in bars
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Verify file exists
|
|
if not os.path.exists(file_path):
|
|
return self._create_result(False, f"Audio file not found: {file_path}")
|
|
|
|
# Create clip at position
|
|
if hasattr(track, 'insert_clip'):
|
|
clip = track.insert_clip(file_path, start_bar, duration)
|
|
return self._create_result(
|
|
True,
|
|
f"Audio clip inserted at bar {start_bar} on track {track_index}",
|
|
{"clip": clip.name if hasattr(clip, 'name') else "unnamed"}
|
|
)
|
|
else:
|
|
# Alternative: use view or clip slots
|
|
if hasattr(self.song, 'view') and hasattr(self.song.view, 'detail_clip'):
|
|
# Method depends on Live version
|
|
pass
|
|
|
|
return self._create_result(
|
|
False,
|
|
"Arrangement clip insertion not available in this Live version"
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to insert arrangement clip: {str(e)}")
|
|
|
|
def insert_arrangement_midi(self, track_index: int, start_bar: float,
|
|
duration: float, notes: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""
|
|
Insert a MIDI clip with notes into the arrangement.
|
|
|
|
Args:
|
|
track_index: Index of the target MIDI track
|
|
start_bar: Start position in bars
|
|
duration: Duration in bars
|
|
notes: List of note dictionaries with pitch, start_time, duration, velocity
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
# Create MIDI clip
|
|
if hasattr(track, 'create_clip'):
|
|
clip = track.create_clip(start_bar, duration)
|
|
|
|
# Add notes
|
|
if hasattr(clip, 'notes') and hasattr(clip.notes, 'add'):
|
|
for note in notes:
|
|
clip.notes.add(
|
|
note["pitch"],
|
|
note.get("start_time", 0),
|
|
note.get("duration", 0.25),
|
|
note.get("velocity", 100),
|
|
False # not muted
|
|
)
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"MIDI clip inserted at bar {start_bar} on track {track_index} with {len(notes)} notes"
|
|
)
|
|
else:
|
|
return self._create_result(
|
|
False,
|
|
"MIDI clip creation not available in this Live version"
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to insert MIDI clip: {str(e)}")
|
|
|
|
def add_automation(self, track_index: int, clip_index: int,
|
|
parameter: str, points: List[Tuple[float, float]]) -> Dict[str, Any]:
|
|
"""
|
|
Add automation envelope points to a clip.
|
|
|
|
Args:
|
|
track_index: Index of the target track
|
|
clip_index: Index of the clip
|
|
parameter: Name of the parameter to automate
|
|
points: List of (time, value) tuples
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
if not hasattr(track, 'clips') or clip_index >= len(track.clips):
|
|
return self._create_result(False, f"Clip index {clip_index} out of range")
|
|
|
|
clip = track.clips[clip_index]
|
|
|
|
# Find parameter to automate
|
|
target_param = None
|
|
if hasattr(clip, 'parameters'):
|
|
for param in clip.parameters:
|
|
if parameter.lower() in param.name.lower():
|
|
target_param = param
|
|
break
|
|
|
|
if target_param is None:
|
|
return self._create_result(False, f"Parameter '{parameter}' not found")
|
|
|
|
# Add automation points
|
|
if hasattr(target_param, 'automation'):
|
|
automation = target_param.automation
|
|
for time, value in points:
|
|
automation.insert_step(time, value, 0) # 0 = linear interpolation
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Added {len(points)} automation points to '{parameter}' in clip {clip_index}"
|
|
)
|
|
else:
|
|
return self._create_result(False, "Automation not available for this parameter")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to add automation: {str(e)}")
|
|
|
|
# =========================================================================
|
|
# Track Management
|
|
# =========================================================================
|
|
|
|
def create_midi_track(self, index: int = -1) -> Dict[str, Any]:
|
|
"""
|
|
Create a new MIDI track.
|
|
|
|
Args:
|
|
index: Position to insert track (-1 for end)
|
|
|
|
Returns:
|
|
Result dictionary with track index if successful
|
|
"""
|
|
try:
|
|
self.song.create_midi_track(index)
|
|
track_index = index if index >= 0 else len(self.song.tracks) - 1
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"MIDI track created at index {track_index}",
|
|
{"track_index": track_index, "type": "midi"}
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to create MIDI track: {str(e)}")
|
|
|
|
def create_audio_track(self, index: int = -1) -> Dict[str, Any]:
|
|
"""
|
|
Create a new audio track.
|
|
|
|
Args:
|
|
index: Position to insert track (-1 for end)
|
|
|
|
Returns:
|
|
Result dictionary with track index if successful
|
|
"""
|
|
try:
|
|
self.song.create_audio_track(index)
|
|
track_index = index if index >= 0 else len(self.song.tracks) - 1
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Audio track created at index {track_index}",
|
|
{"track_index": track_index, "type": "audio"}
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to create audio track: {str(e)}")
|
|
|
|
def set_track_name(self, track_index: int, name: str) -> Dict[str, Any]:
|
|
"""
|
|
Set the name of a track.
|
|
|
|
Args:
|
|
track_index: Index of the track
|
|
name: New name for the track
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
old_name = track.name if hasattr(track, 'name') else "unnamed"
|
|
track.name = name
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Track {track_index} renamed from '{old_name}' to '{name}'"
|
|
)
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set track name: {str(e)}")
|
|
|
|
def set_track_volume(self, track_index: int, volume: float) -> Dict[str, Any]:
|
|
"""
|
|
Set the volume of a track.
|
|
|
|
Args:
|
|
track_index: Index of the track
|
|
volume: Volume level (0.0 - 1.0, or dB scale)
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
if hasattr(track, 'mixer_device') and hasattr(track.mixer_device, 'volume'):
|
|
# Clamp to valid range (0.0 to 1.0 for Live's internal scale)
|
|
clamped_volume = max(0.0, min(1.0, volume))
|
|
track.mixer_device.volume.value = clamped_volume
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Track {track_index} volume set to {clamped_volume:.2f}"
|
|
)
|
|
else:
|
|
return self._create_result(False, "Track doesn't have volume control")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set track volume: {str(e)}")
|
|
|
|
def set_track_pan(self, track_index: int, pan: float) -> Dict[str, Any]:
|
|
"""
|
|
Set the pan of a track.
|
|
|
|
Args:
|
|
track_index: Index of the track
|
|
pan: Pan position (-1.0 left to 1.0 right, 0.0 center)
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if track_index < 0 or track_index >= len(self.song.tracks):
|
|
raise TrackNotFoundError(f"Track index {track_index} out of range")
|
|
|
|
track = self.song.tracks[track_index]
|
|
|
|
if hasattr(track, 'mixer_device') and hasattr(track.mixer_device, 'panning'):
|
|
# Clamp to valid range (-1.0 to 1.0)
|
|
clamped_pan = max(-1.0, min(1.0, pan))
|
|
track.mixer_device.panning.value = clamped_pan
|
|
|
|
return self._create_result(
|
|
True,
|
|
f"Track {track_index} pan set to {clamped_pan:.2f}"
|
|
)
|
|
else:
|
|
return self._create_result(False, "Track doesn't have pan control")
|
|
|
|
except TrackNotFoundError as e:
|
|
return self._create_result(False, str(e))
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set track pan: {str(e)}")
|
|
|
|
def _set_track_mute_internal(self, track_index: int, mute: bool) -> Dict[str, Any]:
|
|
"""Internal method to set track mute state."""
|
|
try:
|
|
track = self.song.tracks[track_index]
|
|
if hasattr(track, 'mute'):
|
|
track.mute = mute
|
|
return self._create_result(True, f"Track {track_index} mute set to {mute}")
|
|
else:
|
|
return self._create_result(False, "Track doesn't support mute")
|
|
except Exception as e:
|
|
return self._create_result(False, str(e))
|
|
|
|
def _set_track_solo_internal(self, track_index: int, solo: bool) -> Dict[str, Any]:
|
|
"""Internal method to set track solo state."""
|
|
try:
|
|
track = self.song.tracks[track_index]
|
|
if hasattr(track, 'solo'):
|
|
track.solo = solo
|
|
return self._create_result(True, f"Track {track_index} solo set to {solo}")
|
|
else:
|
|
return self._create_result(False, "Track doesn't support solo")
|
|
except Exception as e:
|
|
return self._create_result(False, str(e))
|
|
|
|
# =========================================================================
|
|
# Playback Control
|
|
# =========================================================================
|
|
|
|
def start_playback(self) -> Dict[str, Any]:
|
|
"""
|
|
Start playback.
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if hasattr(self.song, 'start_playing'):
|
|
self.song.start_playing()
|
|
return self._create_result(True, "Playback started")
|
|
elif hasattr(self.song, 'is_playing'):
|
|
# Alternative method
|
|
self.song.is_playing = True
|
|
return self._create_result(True, "Playback started")
|
|
else:
|
|
return self._create_result(False, "Playback control not available")
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to start playback: {str(e)}")
|
|
|
|
def stop_playback(self) -> Dict[str, Any]:
|
|
"""
|
|
Stop playback.
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if hasattr(self.song, 'stop_playing'):
|
|
self.song.stop_playing()
|
|
return self._create_result(True, "Playback stopped")
|
|
elif hasattr(self.song, 'is_playing'):
|
|
self.song.is_playing = False
|
|
return self._create_result(True, "Playback stopped")
|
|
else:
|
|
return self._create_result(False, "Playback control not available")
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to stop playback: {str(e)}")
|
|
|
|
def set_tempo(self, bpm: float) -> Dict[str, Any]:
|
|
"""
|
|
Set the project tempo.
|
|
|
|
Args:
|
|
bpm: Tempo in beats per minute
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if hasattr(self.song, 'tempo'):
|
|
# Clamp to reasonable range
|
|
clamped_bpm = max(20.0, min(999.0, bpm))
|
|
self.song.tempo = clamped_bpm
|
|
return self._create_result(True, f"Tempo set to {clamped_bpm:.1f} BPM")
|
|
else:
|
|
return self._create_result(False, "Tempo control not available")
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set tempo: {str(e)}")
|
|
|
|
def set_playhead(self, bar: float) -> Dict[str, Any]:
|
|
"""
|
|
Set the playhead position.
|
|
|
|
Args:
|
|
bar: Position in bars (can include fractional bars)
|
|
|
|
Returns:
|
|
Result dictionary indicating success/failure
|
|
"""
|
|
try:
|
|
if hasattr(self.song, 'current_song_time'):
|
|
# Convert bars to seconds based on tempo
|
|
beats_per_bar = self.song.signature_numerator if hasattr(self.song, 'signature_numerator') else 4
|
|
seconds_per_beat = 60.0 / self.song.tempo
|
|
seconds = bar * beats_per_bar * seconds_per_beat
|
|
|
|
self.song.current_song_time = seconds
|
|
return self._create_result(True, f"Playhead set to bar {bar}")
|
|
else:
|
|
return self._create_result(False, "Playhead control not available")
|
|
|
|
except Exception as e:
|
|
return self._create_result(False, f"Failed to set playhead: {str(e)}")
|