""" 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)}")