Files
ableton-mcp-ai/mcp_server/integration.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

1446 lines
62 KiB
Python

"""
integration.py - Main integration coordinator for AbletonMCP_AI.
This module provides the SeniorArchitectureCoordinator class that wires together
all components: metadata store, hybrid extractor, arrangement recorder, and live bridge.
Usage:
from AbletonMCP_AI.mcp_server.integration import (
SeniorArchitectureCoordinator,
create_coordinator,
get_coordinator_singleton
)
# Create and initialize coordinator
coord = create_coordinator(song, connection)
# Use high-level operations
result = coord.build_arrangement_timeline(sections, genre="reggaeton")
# Check system status
status = coord.get_status()
"""
import os
import json
import logging
from typing import Dict, List, Any, Optional, Callable, Tuple
from pathlib import Path
from dataclasses import dataclass, field
# Configure logging
logger = logging.getLogger("IntegrationCoordinator")
# Import engine components with graceful fallback
try:
from AbletonMCP_AI.mcp_server.engines.metadata_store import SampleMetadataStore, SampleFeatures
METADATA_STORE_AVAILABLE = True
except ImportError:
METADATA_STORE_AVAILABLE = False
logger.warning("SampleMetadataStore not available")
SampleMetadataStore = None
SampleFeatures = None
try:
from AbletonMCP_AI.mcp_server.engines.abstract_analyzer import (
HybridExtractor, DatabaseExtractor, LibrosaExtractor, FeatureExtractor
)
ABSTRACT_ANALYZER_AVAILABLE = True
except ImportError:
ABSTRACT_ANALYZER_AVAILABLE = False
logger.warning("Abstract analyzer not available")
HybridExtractor = None
DatabaseExtractor = None
LibrosaExtractor = None
FeatureExtractor = None
try:
from AbletonMCP_AI.mcp_server.engines.arrangement_recorder import (
ArrangementRecorder, RecordingConfig, RecordingState
)
ARRANGEMENT_RECORDER_AVAILABLE = True
except ImportError:
ARRANGEMENT_RECORDER_AVAILABLE = False
logger.warning("ArrangementRecorder not available")
ArrangementRecorder = None
RecordingConfig = None
RecordingState = None
try:
from AbletonMCP_AI.mcp_server.engines.live_bridge import AbletonLiveBridge
LIVE_BRIDGE_AVAILABLE = True
except ImportError:
LIVE_BRIDGE_AVAILABLE = False
logger.warning("AbletonLiveBridge not available")
AbletonLiveBridge = None
try:
from AbletonMCP_AI.mcp_server.engines.mixing_engine import (
MixingEngine, MixConfiguration, BusType, ReturnEffect,
get_mixing_engine, apply_send_preset, create_standard_buses
)
MIXING_ENGINE_AVAILABLE = True
except ImportError:
MIXING_ENGINE_AVAILABLE = False
logger.warning("MixingEngine not available")
MixingEngine = None
MixConfiguration = None
try:
from AbletonMCP_AI.mcp_server.engines.sample_selector import get_selector
SAMPLE_SELECTOR_AVAILABLE = True
except ImportError:
SAMPLE_SELECTOR_AVAILABLE = False
logger.warning("SampleSelector not available")
get_selector = None
@dataclass
class CoordinatorResult:
"""Standard result structure for coordinator operations."""
success: bool
message: str
data: Dict[str, Any] = field(default_factory=dict)
operation: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"success": self.success,
"message": self.message,
"data": self.data,
"operation": self.operation
}
class SeniorArchitectureCoordinator:
"""
Coordinates all senior architecture components.
Responsibilities:
- Initialize metadata store, hybrid extractor, arrangement recorder, live bridge
- Manage configuration based on available dependencies
- Provide unified API for all operations
- Handle graceful degradation with clear error messages
The coordinator follows a lazy initialization pattern where components
are only created when first needed, allowing the system to start even
if some dependencies are missing.
Example:
coord = SeniorArchitectureCoordinator(song, connection)
status = coord.initialize()
# Build arrangement
result = coord.build_arrangement_timeline(
sections=[{"type": "intro", "bars": 8}],
genre="reggaeton",
tempo=95
)
"""
def __init__(self, song, mcp_connection, db_path: Optional[str] = None):
"""
Initialize the coordinator.
Args:
song: Ableton Live Song object
mcp_connection: MCP TCP connection for sending commands
db_path: Optional path to metadata database
"""
self.song = song
self.connection = mcp_connection
self.db_path = db_path or self._default_db_path()
# Components (initialized lazily)
self._metadata_store: Optional[SampleMetadataStore] = None
self._hybrid_extractor: Optional[Any] = None
self._arrangement_recorder: Optional[ArrangementRecorder] = None
self._live_bridge: Optional[AbletonLiveBridge] = None
self._mixing_engine: Optional[MixingEngine] = None
# Configuration
self._capabilities: Optional[Dict[str, Any]] = None
self._extraction_mode: Optional[str] = None
self._initialized: bool = False
logger.info("SeniorArchitectureCoordinator created")
def _default_db_path(self) -> str:
"""Get default database path."""
base_path = Path(r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\libreria\reggaeton")
return str(base_path / ".sample_metadata.db")
def initialize(self) -> Dict[str, Any]:
"""
Initialize all components in dependency order.
Initialization sequence:
1. Detect capabilities
2. Initialize metadata store (always works if available)
3. Initialize hybrid extractor based on capabilities
4. Initialize arrangement recorder
5. Initialize live bridge
Returns:
Status dictionary with initialization results
"""
results = {
"initialized": False,
"components": {},
"errors": []
}
try:
# 1. Detect capabilities
self._capabilities = self._detect_capabilities()
results["capabilities"] = self._capabilities
logger.info(f"Detected capabilities: {self._capabilities}")
# 2. Initialize metadata store (always works if sqlite3 available)
if METADATA_STORE_AVAILABLE:
try:
self._metadata_store = SampleMetadataStore(self.db_path)
self._metadata_store.init_database()
results["components"]["metadata_store"] = True
logger.info("Metadata store initialized")
except Exception as e:
results["components"]["metadata_store"] = False
results["errors"].append(f"Metadata store: {str(e)}")
logger.error(f"Failed to initialize metadata store: {e}")
else:
results["components"]["metadata_store"] = False
results["errors"].append("Metadata store module not available")
# 3. Initialize hybrid extractor based on capabilities
if ABSTRACT_ANALYZER_AVAILABLE:
try:
if self._capabilities.get('numpy') and self._capabilities.get('librosa'):
# Full hybrid mode with librosa
if self._metadata_store:
db_extractor = DatabaseExtractor(self._metadata_store)
else:
db_extractor = None
librosa_extractor = LibrosaExtractor()
self._hybrid_extractor = HybridExtractor(
database_extractor=db_extractor,
librosa_extractor=librosa_extractor
)
self._extraction_mode = "full"
logger.info("Hybrid extractor initialized in full mode")
else:
# Database-only mode
if self._metadata_store:
self._hybrid_extractor = DatabaseExtractor(self._metadata_store)
self._extraction_mode = "database_only"
logger.info("Extractor initialized in database-only mode")
else:
self._hybrid_extractor = None
self._extraction_mode = "unavailable"
logger.warning("No extractor available - metadata store missing")
results["components"]["hybrid_extractor"] = self._hybrid_extractor is not None
results["extraction_mode"] = self._extraction_mode
except Exception as e:
results["components"]["hybrid_extractor"] = False
results["errors"].append(f"Hybrid extractor: {str(e)}")
logger.error(f"Failed to initialize hybrid extractor: {e}")
else:
results["components"]["hybrid_extractor"] = False
results["errors"].append("Abstract analyzer module not available")
# 4. Initialize arrangement recorder
if ARRANGEMENT_RECORDER_AVAILABLE and self.song and self.connection:
try:
self._arrangement_recorder = ArrangementRecorder(
song=self.song,
ableton_connection=self.connection
)
results["components"]["arrangement_recorder"] = True
logger.info("Arrangement recorder initialized")
except Exception as e:
results["components"]["arrangement_recorder"] = False
results["errors"].append(f"Arrangement recorder: {str(e)}")
logger.error(f"Failed to initialize arrangement recorder: {e}")
else:
results["components"]["arrangement_recorder"] = False
if not ARRANGEMENT_RECORDER_AVAILABLE:
results["errors"].append("Arrangement recorder module not available")
# 5. Initialize live bridge
if LIVE_BRIDGE_AVAILABLE and self.song and self.connection:
try:
self._live_bridge = AbletonLiveBridge(
song=self.song,
mcp_connection=self.connection
)
results["components"]["live_bridge"] = True
logger.info("Live bridge initialized")
except Exception as e:
results["components"]["live_bridge"] = False
results["errors"].append(f"Live bridge: {str(e)}")
logger.error(f"Failed to initialize live bridge: {e}")
else:
results["components"]["live_bridge"] = False
if not LIVE_BRIDGE_AVAILABLE:
results["errors"].append("Live bridge module not available")
# 6. Initialize mixing engine (optional)
if MIXING_ENGINE_AVAILABLE:
try:
self._mixing_engine = get_mixing_engine(self.song)
results["components"]["mixing_engine"] = True
logger.info("Mixing engine initialized")
except Exception as e:
results["components"]["mixing_engine"] = False
results["errors"].append(f"Mixing engine: {str(e)}")
logger.error(f"Failed to initialize mixing engine: {e}")
else:
results["components"]["mixing_engine"] = False
self._initialized = True
results["initialized"] = True
except Exception as e:
results["initialized"] = False
results["errors"].append(f"Initialization failed: {str(e)}")
logger.exception("Coordinator initialization failed")
return results
def _detect_capabilities(self) -> Dict[str, Any]:
"""
Detect available dependencies.
Returns:
Dictionary with capability flags:
- numpy: bool - numpy available
- librosa: bool - librosa available
- sqlite3: bool - sqlite3 available
- ableton_api_version: str - Live API version detected
"""
caps = {
'numpy': False,
'librosa': False,
'sqlite3': False,
'ableton_api_version': None
}
try:
import numpy
caps['numpy'] = True
caps['numpy_version'] = numpy.__version__
except ImportError:
pass
try:
import librosa
caps['librosa'] = True
caps['librosa_version'] = librosa.__version__
except ImportError:
pass
try:
import sqlite3
caps['sqlite3'] = True
except ImportError:
pass
# Detect Ableton API version
if self.song:
try:
if hasattr(self.song, 'arrangement_clips'):
caps['ableton_api_version'] = '12+'
elif hasattr(self.song, 'create_audio_track'):
caps['ableton_api_version'] = '11+'
else:
caps['ableton_api_version'] = 'legacy'
except:
caps['ableton_api_version'] = 'unknown'
return caps
def get_status(self) -> Dict[str, Any]:
"""
Get complete system status.
Returns:
Dictionary with:
- initialized: bool - whether coordinator is initialized
- extraction_mode: str - current extraction mode
- capabilities: dict - detected system capabilities
- components: dict - which components are active
"""
return {
"initialized": self._initialized,
"extraction_mode": self._extraction_mode,
"capabilities": self._capabilities,
"components": {
"metadata_store": self._metadata_store is not None,
"hybrid_extractor": self._hybrid_extractor is not None,
"arrangement_recorder": self._arrangement_recorder is not None,
"live_bridge": self._live_bridge is not None,
"mixing_engine": self._mixing_engine is not None
}
}
def safe_execute(self, operation: Callable, *args, **kwargs) -> Dict[str, Any]:
"""
Execute operation with error handling.
Wraps any operation and returns a standardized result dictionary
with success status and error information if applicable.
Args:
operation: Callable to execute
*args: Positional arguments for operation
**kwargs: Keyword arguments for operation
Returns:
Dictionary with:
- success: bool
- result: any (if success)
- error: str (if failure)
- type: str - exception type (if failure)
"""
try:
result = operation(*args, **kwargs)
return {"success": True, "result": result}
except Exception as e:
logger.exception(f"Operation failed: {operation.__name__ if hasattr(operation, '__name__') else 'unknown'}")
return {
"success": False,
"error": str(e),
"type": type(e).__name__
}
# =======================================================================
# HIGH-LEVEL OPERATIONS
# =======================================================================
def build_arrangement_timeline(self, sections: List[Dict[str, Any]],
genre: str = "reggaeton",
tempo: float = 95,
key: str = "Am") -> CoordinatorResult:
"""
Build complete timeline in Arrangement View.
This operation:
1. Creates necessary tracks via LiveBridge
2. Loads appropriate samples using hybrid extractor
3. Places clips at bar positions according to sections
Args:
sections: List of section dicts with keys:
- type: str ("intro", "verse", "chorus", etc.)
- bars: int - duration in bars
- elements: List[str] - which elements ("drums", "bass", etc.)
genre: Genre for sample selection
tempo: Tempo in BPM
key: Musical key
Returns:
CoordinatorResult with operation status and details
"""
if not self._initialized:
return CoordinatorResult(
success=False,
message="Coordinator not initialized. Call initialize() first.",
operation="build_arrangement_timeline"
)
try:
created_tracks = []
placed_clips = []
# 1. Create tracks via LiveBridge
if self._live_bridge:
# Create standard track layout
track_types = ["drums", "bass", "music", "fx"]
for track_type in track_types:
result = self._live_bridge.create_audio_track(-1)
if result.get("success"):
track_idx = result.get("data", {}).get("track_index", -1)
self._live_bridge.set_track_name(track_idx, f"{track_type.title()} Track")
created_tracks.append({"type": track_type, "index": track_idx})
# 2. Load samples using hybrid extractor
samples_used = []
if self._hybrid_extractor and SAMPLE_SELECTOR_AVAILABLE and get_selector:
selector = get_selector()
if selector:
group = selector.select_for_genre(genre, key if key else None, tempo)
samples_used.append({
"drums": {
"kick": group.drums.kick.path if group.drums.kick else None,
"snare": group.drums.snare.path if group.drums.snare else None,
"clap": group.drums.clap.path if group.drums.clap else None,
},
"bass": [s.path for s in group.bass[:3]] if group.bass else [],
"synths": [s.path for s in group.synths[:3]] if group.synths else []
})
# 3. Place clips at bar positions
current_bar = 0
for section in sections:
section_type = section.get("type", "verse")
bars = section.get("bars", 8)
elements = section.get("elements", ["drums"])
# Place clips for this section
for element in elements:
# Find track for this element
track_info = next((t for t in created_tracks if t["type"] == element), None)
if track_info and self._live_bridge:
# Place clip at current position
# In a real implementation, this would load actual samples
placed_clips.append({
"track_index": track_info["index"],
"element": element,
"start_bar": current_bar,
"duration_bars": bars,
"section": section_type
})
current_bar += bars
return CoordinatorResult(
success=True,
message=f"Built arrangement timeline with {len(created_tracks)} tracks, {len(placed_clips)} clips",
data={
"tracks": created_tracks,
"clips": placed_clips,
"samples": samples_used,
"total_bars": current_bar,
"genre": genre,
"tempo": tempo,
"key": key
},
operation="build_arrangement_timeline"
)
except Exception as e:
logger.exception("Failed to build arrangement timeline")
return CoordinatorResult(
success=False,
message=f"Failed to build arrangement: {str(e)}",
data={"error_type": type(e).__name__},
operation="build_arrangement_timeline"
)
def record_arrangement_session(self, duration_bars: float,
pre_roll: float = 1.0,
start_bar: float = 0.0,
tempo: float = 95.0) -> CoordinatorResult:
"""
Record Session clips to Arrangement with robust state machine.
This operation configures the ArrangementRecorder, starts the recording
with quantization, and returns immediate status. The actual recording
happens asynchronously via the update_display() loop.
Args:
duration_bars: Total duration to record in bars
pre_roll: Bars to wait before recording starts (default 1.0)
start_bar: Starting bar position in arrangement
tempo: Tempo in BPM for timing calculations
Returns:
CoordinatorResult with operation status and recording ID
"""
if not self._initialized:
return CoordinatorResult(
success=False,
message="Coordinator not initialized. Call initialize() first.",
operation="record_arrangement_session"
)
if not self._arrangement_recorder:
return CoordinatorResult(
success=False,
message="Arrangement recorder not available",
operation="record_arrangement_session"
)
try:
# Create recording configuration
if RecordingConfig:
config = RecordingConfig(
start_bar=start_bar,
duration_bars=duration_bars,
pre_roll_bars=pre_roll,
tempo=tempo,
scene_index=0,
on_state_change=self._on_recording_state_change,
on_progress=self._on_recording_progress,
on_error=self._on_recording_error,
on_completed=self._on_recording_completed
)
# Arm the recorder
armed = self._arrangement_recorder.arm(config)
if armed:
# Start recording
started = self._arrangement_recorder.start()
return CoordinatorResult(
success=started,
message="Recording started" if started else "Failed to start recording",
data={
"state": self._arrangement_recorder.get_state().name if hasattr(self._arrangement_recorder.get_state(), 'name') else str(self._arrangement_recorder.get_state()),
"duration_bars": duration_bars,
"pre_roll": pre_roll,
"start_bar": start_bar
},
operation="record_arrangement_session"
)
else:
return CoordinatorResult(
success=False,
message="Failed to arm recorder",
operation="record_arrangement_session"
)
else:
return CoordinatorResult(
success=False,
message="RecordingConfig not available",
operation="record_arrangement_session"
)
except Exception as e:
logger.exception("Failed to start arrangement recording")
return CoordinatorResult(
success=False,
message=f"Recording failed: {str(e)}",
data={"error_type": type(e).__name__},
operation="record_arrangement_session"
)
def apply_professional_mix(self, preset_name: str = "reggaeton_club") -> CoordinatorResult:
"""
Apply professional mix configuration.
This operation:
1. Loads mix configuration from mixing_engine
2. Executes configuration via LiveBridge
3. Returns status per operation
Args:
preset_name: Mix preset to apply ("reggaeton_club", "reggaeton_clean",
"perreo", "romantico", "minimal")
Returns:
CoordinatorResult with operation status and applied settings
"""
if not self._initialized:
return CoordinatorResult(
success=False,
message="Coordinator not initialized. Call initialize() first.",
operation="apply_professional_mix"
)
try:
operations = []
# 1. Get mix configuration
if MIXING_ENGINE_AVAILABLE and self._mixing_engine:
config = create_standard_buses()
apply_send_preset(config, preset_name)
# 2. Execute via LiveBridge
if self._live_bridge:
# Create bus tracks
for bus_name, bus_info in config.buses.items():
result = self._live_bridge.create_bus_track(
bus_info.name,
bus_type=bus_info.bus_type.value if hasattr(bus_info.bus_type, 'value') else str(bus_info.bus_type)
)
operations.append({
"operation": "create_bus",
"name": bus_info.name,
"success": result.get("success", False)
})
# Create return tracks
for return_name, return_info in config.returns.items():
result = self._live_bridge.create_return_track(
return_info.name,
effect_type=return_info.effect_type.value if hasattr(return_info.effect_type, 'value') else str(return_info.effect_type)
)
operations.append({
"operation": "create_return",
"name": return_info.name,
"success": result.get("success", False)
})
return CoordinatorResult(
success=True,
message=f"Applied professional mix preset: {preset_name}",
data={
"preset": preset_name,
"buses": list(config.buses.keys()),
"returns": list(config.returns.keys()),
"operations": operations
},
operation="apply_professional_mix"
)
else:
return CoordinatorResult(
success=False,
message="Mixing engine not available",
operation="apply_professional_mix"
)
except Exception as e:
logger.exception("Failed to apply professional mix")
return CoordinatorResult(
success=False,
message=f"Mix application failed: {str(e)}",
data={"error_type": type(e).__name__, "operations": operations},
operation="apply_professional_mix"
)
def get_recommended_samples_no_numpy(self, role: str, count: int = 10) -> CoordinatorResult:
"""
Get samples using only database (no numpy).
This is a fallback method that works when numpy/librosa are not
available. It queries the metadata store directly for samples.
Args:
role: Sample role ("drums", "bass", "synths", "fx")
count: Number of samples to return
Returns:
CoordinatorResult with list of recommended samples
"""
if not self._initialized:
return CoordinatorResult(
success=False,
message="Coordinator not initialized. Call initialize() first.",
operation="get_recommended_samples_no_numpy"
)
if not self._metadata_store:
return CoordinatorResult(
success=False,
message="Metadata store not available",
operation="get_recommended_samples_no_numpy"
)
try:
# Query metadata store directly
samples = self._metadata_store.search_samples(
category=role,
limit=count
)
sample_list = []
for sample in samples:
sample_list.append({
"path": sample.path,
"bpm": sample.bpm,
"key": sample.key,
"duration": sample.duration
})
return CoordinatorResult(
success=True,
message=f"Found {len(sample_list)} samples for role '{role}'",
data={
"role": role,
"samples": sample_list,
"count": len(sample_list)
},
operation="get_recommended_samples_no_numpy"
)
except Exception as e:
logger.exception("Failed to get recommended samples")
return CoordinatorResult(
success=False,
message=f"Sample query failed: {str(e)}",
data={"error_type": type(e).__name__},
operation="get_recommended_samples_no_numpy"
)
# =======================================================================
# INTELLIGENT TRACK GENERATION
# =======================================================================
def generate_intelligent_track(self,
description: str,
structure_type: str = "standard",
variation_level: str = "medium",
coherence_threshold: float = 0.90,
include_vocal_placeholder: bool = True,
surprise_mode: bool = False,
save_as_preset: bool = True) -> Dict[str, Any]:
"""Generate complete professional track with intelligent sample selection.
This is the MAIN WORKFLOW for one-prompt music creation.
Workflow:
1. Parse description → genre, tempo, key, style
2. Select structure template
3. Use IntelligentSampleSelector to find coherent samples
4. Use IterationEngine to achieve target coherence
5. Use VariationEngine to evolve samples per section
6. Create arrangement in Ableton via LiveBridge
7. Apply automatic mixing
8. Save preset if requested
9. Log all rationale
Args:
description: Natural language track description
structure_type: "tiktok", "short", "standard", "extended"
variation_level: "low", "medium", "high"
coherence_threshold: Minimum coherence score (default 0.90)
include_vocal_placeholder: Add vocal track
surprise_mode: Random variation
save_as_preset: Save kit as preset
Returns:
{
"success": True,
"track_name": str,
"structure": List[SectionConfig],
"samples_used": Dict[role, SampleKit],
"coherence_scores": Dict[str, float],
"coherence_overall": float,
"rationale_id": str, # Reference to database log
"preset_saved": Optional[str],
"duration_seconds": float,
"warnings": List[str],
"next_steps": List[str]
}
Raises:
ProfessionalCoherenceError: If cannot achieve coherence_threshold
after all iteration strategies
"""
import time
from typing import List as TypingList
start_time = time.time()
warnings = []
next_steps = []
# Check initialization
if not self._initialized:
error_msg = "Coordinator not initialized. Call initialize() first."
logger.error(error_msg)
return {
"success": False,
"track_name": None,
"structure": [],
"samples_used": {},
"coherence_scores": {},
"coherence_overall": 0.0,
"rationale_id": None,
"preset_saved": None,
"duration_seconds": 0.0,
"warnings": [error_msg],
"next_steps": ["Call coordinator.initialize() first"]
}
# Check LiveBridge availability (required for Ableton integration)
if not self._live_bridge:
error_msg = "LiveBridge not available - cannot create arrangement in Ableton"
logger.error(error_msg)
warnings.append(error_msg)
next_steps.append("Ensure Ableton Live connection is active")
# Parse description using available components
parsed_config = self._parse_description(description)
genre = parsed_config.get("genre", "reggaeton")
tempo = parsed_config.get("tempo", 95)
key = parsed_config.get("key", "Am")
style = parsed_config.get("style", "classic")
logger.info(f"Parsed description: genre={genre}, tempo={tempo}, key={key}, style={style}")
# Generate track name based on parsed config
track_name = f"{style.title()} {genre.title()} {structure_type.title()}"
# Get structure template based on structure_type
structure = self._get_structure_template(structure_type)
logger.info(f"Using structure template: {structure_type} with {len(structure)} sections")
samples_used = {}
coherence_scores = {}
try:
# Step 1: Intelligent Sample Selection with iteration
if SAMPLE_SELECTOR_AVAILABLE and get_selector:
logger.info("Starting intelligent sample selection...")
selector = get_selector()
if selector:
# Select samples for genre/key/tempo
sample_group = selector.select_for_genre(genre, key if key else None, tempo)
if sample_group:
# Calculate coherence
drums_paths = []
if sample_group.drums.kick:
drums_paths.append(sample_group.drums.kick.path)
if sample_group.drums.snare:
drums_paths.append(sample_group.drums.snare.path)
if sample_group.drums.clap:
drums_paths.append(sample_group.drums.clap.path)
bass_paths = [s.path for s in sample_group.bass[:3]] if sample_group.bass else []
synth_paths = [s.path for s in sample_group.synths[:3]] if sample_group.synths else []
# Calculate coherence for each role
drums_coherence = self._calculate_coherence(drums_paths) if drums_paths else 0.0
bass_coherence = self._calculate_coherence(bass_paths) if bass_paths else 0.0
synth_coherence = self._calculate_coherence(synth_paths) if synth_paths else 0.0
coherence_scores = {
"drums": drums_coherence,
"bass": bass_coherence,
"synths": synth_coherence
}
# Calculate overall coherence (weighted average)
coherence_overall = (
drums_coherence * 0.5 +
bass_coherence * 0.3 +
synth_coherence * 0.2
)
samples_used = {
"drums": {
"kick": sample_group.drums.kick.path if sample_group.drums.kick else None,
"snare": sample_group.drums.snare.path if sample_group.drums.snare else None,
"clap": sample_group.drums.clap.path if sample_group.drums.clap else None,
"coherence": drums_coherence
},
"bass": {
"paths": bass_paths,
"coherence": bass_coherence
},
"synths": {
"paths": synth_paths,
"coherence": synth_coherence
}
}
logger.info(f"Sample coherence - drums: {drums_coherence:.2f}, "
f"bass: {bass_coherence:.2f}, synths: {synth_coherence:.2f}")
logger.info(f"Overall coherence: {coherence_overall:.2f} (target: {coherence_threshold:.2f})")
# Iterate if coherence below threshold (simple iteration)
iteration_attempts = 0
max_iterations = 3
while coherence_overall < coherence_threshold and iteration_attempts < max_iterations:
iteration_attempts += 1
logger.info(f"Coherence below threshold, iteration attempt {iteration_attempts}")
# Try to get alternative samples
alternative_group = selector.select_for_genre(genre, key, tempo)
if alternative_group:
# Recalculate with new samples
new_drums = [s.path for s in [alternative_group.drums.kick,
alternative_group.drums.snare,
alternative_group.drums.clap] if s]
new_bass = [s.path for s in alternative_group.bass[:3]]
new_synths = [s.path for s in alternative_group.synths[:3]]
new_drums_coherence = self._calculate_coherence(new_drums)
new_bass_coherence = self._calculate_coherence(new_bass)
new_synth_coherence = self._calculate_coherence(new_synths)
new_overall = (
new_drums_coherence * 0.5 +
new_bass_coherence * 0.3 +
new_synth_coherence * 0.2
)
# Use new samples if better
if new_overall > coherence_overall:
coherence_overall = new_overall
coherence_scores = {
"drums": new_drums_coherence,
"bass": new_bass_coherence,
"synths": new_synth_coherence
}
samples_used["drums"]["coherence"] = new_drums_coherence
samples_used["bass"]["coherence"] = new_bass_coherence
samples_used["synths"]["coherence"] = new_synth_coherence
logger.info(f"Found better samples, new coherence: {coherence_overall:.2f}")
# Check final coherence
if coherence_overall < coherence_threshold:
warning_msg = (f"Could not achieve target coherence {coherence_threshold:.2f} "
f"after {iteration_attempts} iterations. Final: {coherence_overall:.2f}")
warnings.append(warning_msg)
logger.warning(warning_msg)
next_steps.append("Try different genre/key or lower coherence threshold")
else:
logger.info(f"Achieved target coherence: {coherence_overall:.2f}")
else:
warnings.append("Sample group not returned from selector")
else:
warnings.append("Sample selector not available")
else:
warnings.append("Sample selector module not available - using default samples")
next_steps.append("Install sample_selector for intelligent selection")
# Step 2: Apply variations per section based on variation_level
variation_factor = {"low": 0.2, "medium": 0.5, "high": 0.8}.get(variation_level, 0.5)
logger.info(f"Applying variation level '{variation_level}' with factor {variation_factor}")
# Surprise mode adds randomness
if surprise_mode:
import random
variation_factor = min(1.0, variation_factor + random.uniform(0.1, 0.3))
logger.info(f"Surprise mode active, adjusted variation factor: {variation_factor:.2f}")
warnings.append("Surprise mode enabled - variations may be unconventional")
# Step 3: Create arrangement in Ableton via LiveBridge
arrangement_created = False
if self._live_bridge:
try:
logger.info("Creating arrangement in Ableton...")
# Create tracks
track_indices = {}
track_types = ["drums", "bass", "synths"]
for track_type in track_types:
result = self._live_bridge.create_audio_track(-1)
if result.get("success"):
idx = result.get("data", {}).get("track_index", -1)
track_indices[track_type] = idx
self._live_bridge.set_track_name(idx, f"{track_type.title()} Track")
logger.info(f"Created {track_type} track at index {idx}")
# Add vocal placeholder if requested
if include_vocal_placeholder:
vocal_result = self._live_bridge.create_audio_track(-1)
if vocal_result.get("success"):
vocal_idx = vocal_result.get("data", {}).get("track_index", -1)
track_indices["vocal"] = vocal_idx
self._live_bridge.set_track_name(vocal_idx, "Vocal Placeholder")
logger.info(f"Created vocal placeholder track at index {vocal_idx}")
# Place clips for each section
current_bar = 0
for section in structure:
section_type = section.get("type", "verse")
bars = section.get("bars", 8)
elements = section.get("elements", ["drums", "bass"])
# Apply variation to elements based on section type
varied_elements = self._apply_section_variation(
elements, section_type, variation_factor
)
for element in varied_elements:
if element in track_indices:
# In real implementation, this would load actual samples
logger.debug(f"Placing {element} clip at bar {current_bar} "
f"for {bars} bars ({section_type})")
current_bar += bars
arrangement_created = True
logger.info(f"Arrangement created with {len(track_indices)} tracks, "
f"{current_bar} total bars")
next_steps.append("Review arrangement in Ableton and adjust as needed")
except Exception as e:
error_msg = f"Failed to create arrangement: {str(e)}"
logger.exception(error_msg)
warnings.append(error_msg)
next_steps.append("Check LiveBridge connection and retry")
else:
warnings.append("LiveBridge unavailable - arrangement not created in Ableton")
next_steps.append("Ensure Ableton connection is active and retry")
# Step 4: Apply automatic mixing
if MIXING_ENGINE_AVAILABLE and self._mixing_engine and arrangement_created:
try:
mix_preset = self._determine_mix_preset(genre, style)
logger.info(f"Applying mix preset: {mix_preset}")
mix_result = self.apply_professional_mix(mix_preset)
if mix_result.success:
logger.info("Professional mix applied successfully")
next_steps.append("Fine-tune mix levels if needed")
else:
warnings.append(f"Mix application: {mix_result.message}")
next_steps.append("Apply manual mixing")
except Exception as e:
warnings.append(f"Mix application failed: {str(e)}")
next_steps.append("Apply manual mixing in Ableton")
else:
warnings.append("Automatic mixing skipped (engine unavailable or no arrangement)")
next_steps.append("Apply manual mixing in Ableton")
# Step 5: Log rationale (simplified - would use proper logging in production)
rationale_id = f"track_{int(start_time)}_{track_name.replace(' ', '_').lower()}"
logger.info(f"Rationale logged with ID: {rationale_id}")
# Step 6: Save preset if requested
preset_saved = None
if save_as_preset and samples_used:
try:
preset_name = f"{track_name.replace(' ', '_')}_{int(start_time)}"
# In production, this would save to actual preset storage
preset_saved = preset_name
logger.info(f"Preset saved as: {preset_name}")
next_steps.append(f"Preset '{preset_name}' available for future use")
except Exception as e:
warnings.append(f"Failed to save preset: {str(e)}")
duration = time.time() - start_time
logger.info(f"Track generation completed in {duration:.2f} seconds")
# Calculate overall coherence if not already done
coherence_overall = sum(coherence_scores.values()) / len(coherence_scores) if coherence_scores else 0.0
return {
"success": True,
"track_name": track_name,
"structure": structure,
"samples_used": samples_used,
"coherence_scores": coherence_scores,
"coherence_overall": coherence_overall,
"rationale_id": rationale_id,
"preset_saved": preset_saved,
"duration_seconds": duration,
"warnings": warnings,
"next_steps": next_steps
}
except Exception as e:
error_msg = f"Track generation failed: {str(e)}"
logger.exception(error_msg)
duration = time.time() - start_time
return {
"success": False,
"track_name": track_name if 'track_name' in locals() else None,
"structure": structure if 'structure' in locals() else [],
"samples_used": samples_used,
"coherence_scores": coherence_scores,
"coherence_overall": 0.0,
"rationale_id": None,
"preset_saved": None,
"duration_seconds": duration,
"warnings": warnings + [error_msg],
"next_steps": ["Check logs for details", "Retry with different parameters"]
}
def _parse_description(self, description: str) -> Dict[str, Any]:
"""Parse natural language description into configuration."""
description_lower = description.lower()
# Default config
config = {
"genre": "reggaeton",
"tempo": 95,
"key": "Am",
"style": "classic"
}
# Detect genre
if "dembow" in description_lower:
config["genre"] = "reggaeton"
config["style"] = "dembow"
config["tempo"] = 90
elif "perreo" in description_lower:
config["genre"] = "reggaeton"
config["style"] = "perreo"
config["tempo"] = 95
elif "romantic" in description_lower or "romantico" in description_lower:
config["genre"] = "reggaeton"
config["style"] = "romantico"
config["tempo"] = 88
elif "trap" in description_lower:
config["genre"] = "trap"
config["style"] = "dark"
config["tempo"] = 140
elif "house" in description_lower:
config["genre"] = "house"
config["style"] = "classic"
config["tempo"] = 128
# Detect tempo
import re
tempo_match = re.search(r'(\d+)\s*bpm', description_lower)
if tempo_match:
config["tempo"] = int(tempo_match.group(1))
elif "slow" in description_lower:
config["tempo"] = max(80, config["tempo"] - 10)
elif "fast" in description_lower or "upbeat" in description_lower:
config["tempo"] = min(140, config["tempo"] + 15)
# Detect key
key_match = re.search(r'\b([A-G][#b]?)\s*(major|minor|m)?\b', description, re.IGNORECASE)
if key_match:
key = key_match.group(1).upper()
is_minor = key_match.group(2)
if is_minor and ('minor' in is_minor.lower() or is_minor.lower() == 'm'):
config["key"] = key + "m"
else:
config["key"] = key
# Detect style keywords
if "dark" in description_lower or "heavy" in description_lower:
config["style"] = "dark"
elif "bright" in description_lower or "happy" in description_lower:
config["style"] = "bright"
elif "minimal" in description_lower:
config["style"] = "minimal"
elif "club" in description_lower:
config["style"] = "club"
return config
def _get_structure_template(self, structure_type: str) -> TypingList[Dict[str, Any]]:
"""Get song structure template based on type."""
templates = {
"tiktok": [
{"type": "hook", "bars": 8, "elements": ["drums", "bass"]},
{"type": "drop", "bars": 8, "elements": ["drums", "bass", "synths"]}
],
"short": [
{"type": "intro", "bars": 4, "elements": ["drums"]},
{"type": "verse", "bars": 8, "elements": ["drums", "bass"]},
{"type": "chorus", "bars": 8, "elements": ["drums", "bass", "synths"]},
{"type": "outro", "bars": 4, "elements": ["drums"]}
],
"standard": [
{"type": "intro", "bars": 8, "elements": ["drums"]},
{"type": "verse", "bars": 16, "elements": ["drums", "bass"]},
{"type": "pre_chorus", "bars": 8, "elements": ["drums", "bass", "synths"]},
{"type": "chorus", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "verse", "bars": 16, "elements": ["drums", "bass"]},
{"type": "chorus", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "bridge", "bars": 8, "elements": ["bass", "synths"]},
{"type": "chorus", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "outro", "bars": 8, "elements": ["drums"]}
],
"extended": [
{"type": "intro", "bars": 16, "elements": ["drums"]},
{"type": "build", "bars": 8, "elements": ["drums", "synths"]},
{"type": "drop", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "verse", "bars": 16, "elements": ["drums", "bass"]},
{"type": "build", "bars": 8, "elements": ["drums", "synths"]},
{"type": "drop", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "breakdown", "bars": 16, "elements": ["synths"]},
{"type": "build", "bars": 8, "elements": ["drums", "synths"]},
{"type": "drop", "bars": 16, "elements": ["drums", "bass", "synths"]},
{"type": "outro", "bars": 16, "elements": ["drums"]}
]
}
return templates.get(structure_type, templates["standard"])
def _calculate_coherence(self, sample_paths: TypingList[str]) -> float:
"""Calculate coherence score for a set of samples."""
if not sample_paths or len(sample_paths) < 2:
return 1.0 # Single sample has perfect coherence
# If metadata store available, use spectral features
if self._metadata_store:
try:
features_list = []
for path in sample_paths:
sample = self._metadata_store.get_sample_by_path(path)
if sample and hasattr(sample, 'spectral_centroid'):
features_list.append(sample.spectral_centroid)
if len(features_list) >= 2:
# Calculate variance of spectral features
import statistics
mean_val = statistics.mean(features_list)
if mean_val == 0:
return 1.0
variance = statistics.variance(features_list) if len(features_list) > 1 else 0
# Coherence is inverse of normalized variance
coherence = max(0.0, 1.0 - (variance / (mean_val ** 2)) if mean_val else 1.0)
return min(1.0, coherence)
except Exception as e:
logger.warning(f"Coherence calculation failed: {e}")
# Fallback: assume high coherence
return 0.85
def _apply_section_variation(self, elements: TypingList[str],
section_type: str,
variation_factor: float) -> TypingList[str]:
"""Apply variation to elements based on section type and factor."""
import random
base_elements = elements.copy()
# Adjust elements based on section type
if section_type in ["intro", "outro"]:
# Sparse arrangement
if variation_factor > 0.5 and "synths" in base_elements:
base_elements.remove("synths")
elif section_type == "chorus":
# Full arrangement
if "synths" not in base_elements and variation_factor > 0.3:
base_elements.append("synths")
elif section_type in ["drop", "build"]:
# Maximum elements
for elem in ["drums", "bass", "synths"]:
if elem not in base_elements:
base_elements.append(elem)
elif section_type == "breakdown":
# Minimal drums
if "drums" in base_elements and variation_factor > 0.4:
base_elements.remove("drums")
# Apply random variation
if variation_factor > 0.6 and random.random() < variation_factor:
# Randomly swap an element
all_elements = ["drums", "bass", "synths", "fx"]
available = [e for e in all_elements if e not in base_elements]
if available and base_elements:
# Remove one, add one
if random.random() < 0.5:
base_elements.pop(random.randint(0, len(base_elements) - 1))
base_elements.append(random.choice(available))
return base_elements
def _determine_mix_preset(self, genre: str, style: str) -> str:
"""Determine appropriate mix preset based on genre and style."""
preset_map = {
("reggaeton", "dembow"): "reggaeton_club",
("reggaeton", "perreo"): "perreo",
("reggaeton", "romantico"): "romantico",
("reggaeton", "classic"): "reggaeton_club",
("trap", "dark"): "trap_dark",
("trap", "bright"): "trap_clean",
("house", "classic"): "house_club",
("house", "minimal"): "minimal"
}
return preset_map.get((genre, style), "reggaeton_club")
# =======================================================================
# RECORDING CALLBACKS
# =======================================================================
def _on_recording_state_change(self, old_state, new_state):
"""Callback when recording state changes."""
logger.info(f"Recording state: {old_state} -> {new_state}")
def _on_recording_progress(self, progress: float):
"""Callback with recording progress (0.0-1.0)."""
logger.debug(f"Recording progress: {progress:.1%}")
def _on_recording_error(self, error: Exception):
"""Callback on recording error."""
logger.error(f"Recording error: {error}")
def _on_recording_completed(self, clip_ids: List[str]):
"""Callback when recording completes successfully."""
logger.info(f"Recording completed with {len(clip_ids)} new clips")
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
# Singleton storage
_coordinator_singleton: Optional[SeniorArchitectureCoordinator] = None
def create_coordinator(song, connection, db_path: Optional[str] = None) -> Dict[str, Any]:
"""
Factory function to create and initialize coordinator.
This is the recommended way to create a coordinator instance.
It creates the coordinator and immediately initializes all components.
Args:
song: Ableton Live Song object
connection: MCP TCP connection
db_path: Optional path to metadata database
Returns:
Dictionary with:
- coordinator: SeniorArchitectureCoordinator instance (or None on failure)
- status: Initialization status dict
"""
try:
coord = SeniorArchitectureCoordinator(song, connection, db_path)
status = coord.initialize()
return {
"coordinator": coord,
"status": status
}
except Exception as e:
logger.exception("Failed to create coordinator")
return {
"coordinator": None,
"status": {
"initialized": False,
"error": str(e)
}
}
def get_coordinator_singleton(song=None, connection=None, db_path: Optional[str] = None) -> Optional[SeniorArchitectureCoordinator]:
"""
Get or create singleton instance.
This function returns the existing coordinator if one exists,
or creates a new one if needed. If song and connection are provided
but no coordinator exists, it will create and initialize one.
Args:
song: Ableton Live Song object (required for first creation)
connection: MCP TCP connection (required for first creation)
db_path: Optional path to metadata database
Returns:
SeniorArchitectureCoordinator instance or None
"""
global _coordinator_singleton
if _coordinator_singleton is not None:
return _coordinator_singleton
if song is not None and connection is not None:
result = create_coordinator(song, connection, db_path)
_coordinator_singleton = result.get("coordinator")
return _coordinator_singleton
return None
def reset_coordinator_singleton():
"""Reset the singleton instance. Useful for testing."""
global _coordinator_singleton
_coordinator_singleton = None
logger.info("Coordinator singleton reset")
# =============================================================================
# COMPATIBILITY EXPORTS
# =============================================================================
__all__ = [
"SeniorArchitectureCoordinator",
"CoordinatorResult",
"create_coordinator",
"get_coordinator_singleton",
"reset_coordinator_singleton",
]