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
This commit is contained in:
730
mcp_server/engines/arrangement_recorder.py
Normal file
730
mcp_server/engines/arrangement_recorder.py
Normal file
@@ -0,0 +1,730 @@
|
||||
"""
|
||||
ArrangementRecorder - Robust state machine for recording Session to Arrangement.
|
||||
|
||||
This module provides a reliable way to record Session View clips into Arrangement View
|
||||
with proper state management, musical timing, and error handling.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable, List, Dict, Any, Tuple
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecordingState(Enum):
|
||||
"""
|
||||
State machine states for arrangement recording.
|
||||
|
||||
Transitions:
|
||||
IDLE -> ARMED (via arm())
|
||||
ARMED -> PRE_ROLL (via start())
|
||||
PRE_ROLL -> RECORDING (when quantized time reached)
|
||||
RECORDING -> COOLDOWN (when duration elapsed or stop() called)
|
||||
COOLDOWN -> COMPLETED (verification complete)
|
||||
COOLDOWN -> FAILED (verification failed)
|
||||
Any -> IDLE (via reset or error recovery)
|
||||
"""
|
||||
IDLE = auto()
|
||||
ARMED = auto()
|
||||
PRE_ROLL = auto()
|
||||
RECORDING = auto()
|
||||
COOLDOWN = auto()
|
||||
COMPLETED = auto()
|
||||
FAILED = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingConfig:
|
||||
"""
|
||||
Configuration for arrangement recording session.
|
||||
|
||||
Attributes:
|
||||
start_bar: Starting bar position in arrangement
|
||||
duration_bars: Total duration to record in bars
|
||||
pre_roll_bars: Bars to wait before recording starts (default 1.0)
|
||||
tempo: Tempo in BPM for timing calculations
|
||||
scene_index: Scene to fire at start (default 0)
|
||||
on_state_change: Callback when state changes (old_state, new_state)
|
||||
on_progress: Callback with progress 0.0-1.0
|
||||
on_error: Callback with exception on failure
|
||||
on_completed: Callback with list of new clip IDs on success
|
||||
"""
|
||||
start_bar: float
|
||||
duration_bars: float
|
||||
pre_roll_bars: float = 1.0
|
||||
tempo: float = 95.0
|
||||
scene_index: int = 0
|
||||
on_state_change: Optional[Callable[[RecordingState, RecordingState], None]] = None
|
||||
on_progress: Optional[Callable[[float], None]] = None
|
||||
on_error: Optional[Callable[[Exception], None]] = None
|
||||
on_completed: Optional[Callable[[List[str]], None]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate configuration parameters."""
|
||||
if self.start_bar < 0:
|
||||
raise ValueError(f"start_bar must be >= 0, got {self.start_bar}")
|
||||
if self.duration_bars <= 0:
|
||||
raise ValueError(f"duration_bars must be > 0, got {self.duration_bars}")
|
||||
if self.pre_roll_bars < 0:
|
||||
raise ValueError(f"pre_roll_bars must be >= 0, got {self.pre_roll_bars}")
|
||||
if self.tempo <= 0:
|
||||
raise ValueError(f"tempo must be > 0, got {self.tempo}")
|
||||
if self.scene_index < 0:
|
||||
raise ValueError(f"scene_index must be >= 0, got {self.scene_index}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArrangementBaseline:
|
||||
"""
|
||||
Captured state of arrangement before recording.
|
||||
Used for verification after recording completes.
|
||||
"""
|
||||
clip_count: int
|
||||
clip_ids: set
|
||||
clip_positions: Dict[str, Tuple[float, float]] # id -> (start, end)
|
||||
total_length: float
|
||||
timestamp: float
|
||||
|
||||
|
||||
class ArrangementRecorder:
|
||||
"""
|
||||
Robust recorder for Session to Arrangement with state machine.
|
||||
|
||||
This class manages the entire recording lifecycle:
|
||||
- Pre-recording verification and setup
|
||||
- Musical timing (bars/beats) instead of wall-clock
|
||||
- Quantized start on bar boundaries
|
||||
- Automatic stop after duration
|
||||
- Post-recording verification
|
||||
|
||||
Usage:
|
||||
recorder = ArrangementRecorder(song, ableton_connection)
|
||||
config = RecordingConfig(start_bar=0, duration_bars=8, tempo=95)
|
||||
|
||||
if recorder.arm(config):
|
||||
recorder.start() # Call from update_display() loop
|
||||
|
||||
# In update_display():
|
||||
recorder.update() # Processes state machine
|
||||
"""
|
||||
|
||||
def __init__(self, song, ableton_connection):
|
||||
"""
|
||||
Initialize the arrangement recorder.
|
||||
|
||||
Args:
|
||||
song: Live.Song.Song object
|
||||
ableton_connection: Connection object for sending commands to Live
|
||||
"""
|
||||
self.song = song
|
||||
self.ableton = ableton_connection
|
||||
|
||||
# State machine
|
||||
self._state = RecordingState.IDLE
|
||||
self._config: Optional[RecordingConfig] = None
|
||||
|
||||
# Recording data
|
||||
self._baseline: Optional[ArrangementBaseline] = None
|
||||
self._new_clips: List[str] = []
|
||||
self._new_clip_ids: set = set()
|
||||
|
||||
# Timing (musical - in bars/beats)
|
||||
self._target_start_bar: float = 0.0
|
||||
self._target_end_bar: float = 0.0
|
||||
self._pre_roll_target_bar: float = 0.0
|
||||
self._current_progress: float = 0.0
|
||||
|
||||
# Update tracking
|
||||
self._last_update_time: float = 0.0
|
||||
self._last_progress_emit: float = -1.0
|
||||
self._state_entry_time: float = 0.0
|
||||
|
||||
logger.info("ArrangementRecorder initialized")
|
||||
|
||||
# ========================================================================
|
||||
# PUBLIC API
|
||||
# ========================================================================
|
||||
|
||||
def arm(self, config: RecordingConfig) -> bool:
|
||||
"""
|
||||
Arm the recorder with configuration.
|
||||
|
||||
Verifies preconditions and captures baseline state.
|
||||
Must be called before start().
|
||||
|
||||
Args:
|
||||
config: Recording configuration
|
||||
|
||||
Returns:
|
||||
True if successfully armed, False otherwise
|
||||
"""
|
||||
if self._state != RecordingState.IDLE:
|
||||
logger.warning(f"Cannot arm from state {self._state.name}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Validate config
|
||||
self._config = config
|
||||
|
||||
# Verify preconditions
|
||||
self._verify_preconditions()
|
||||
|
||||
# Capture baseline
|
||||
self._baseline = self._capture_baseline()
|
||||
|
||||
# Transition to ARMED
|
||||
self._transition_to(RecordingState.ARMED)
|
||||
|
||||
logger.info(f"Recorder armed: bar {config.start_bar}, "
|
||||
f"duration {config.duration_bars} bars, "
|
||||
f"pre-roll {config.pre_roll_bars} bars")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to arm recorder: {e}")
|
||||
self._handle_error(e)
|
||||
return False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Start the recording process.
|
||||
|
||||
Begins pre-roll phase if armed. Recording will start
|
||||
automatically on the next bar boundary after pre-roll.
|
||||
|
||||
Returns:
|
||||
True if recording sequence started, False otherwise
|
||||
"""
|
||||
if self._state != RecordingState.ARMED:
|
||||
logger.warning(f"Cannot start from state {self._state.name}")
|
||||
return False
|
||||
|
||||
if not self._config:
|
||||
logger.error("No configuration set")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Calculate timing
|
||||
current_bar = self._get_current_bar()
|
||||
self._pre_roll_target_bar = current_bar + self._config.pre_roll_bars
|
||||
self._target_start_bar = self._pre_roll_target_bar
|
||||
self._target_end_bar = self._target_start_bar + self._config.duration_bars
|
||||
|
||||
# Enable arrangement overdub
|
||||
self.song.arrangement_overdub = True
|
||||
|
||||
# Transition to PRE_ROLL
|
||||
self._transition_to(RecordingState.PRE_ROLL)
|
||||
|
||||
logger.info(f"Recording sequence started: pre-roll until bar {self._pre_roll_target_bar}, "
|
||||
f"recording until bar {self._target_end_bar}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start recording: {e}")
|
||||
self._handle_error(e)
|
||||
return False
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
Manually stop the recording.
|
||||
|
||||
Can be called during PRE_ROLL or RECORDING states.
|
||||
|
||||
Returns:
|
||||
True if stopped successfully, False otherwise
|
||||
"""
|
||||
if self._state not in (RecordingState.PRE_ROLL, RecordingState.RECORDING):
|
||||
logger.warning(f"Cannot stop from state {self._state.name}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Stop playback
|
||||
self.song.stop_playing()
|
||||
|
||||
# Disable overdub
|
||||
self.song.arrangement_overdub = False
|
||||
|
||||
# Calculate actual end position
|
||||
actual_end = self._get_current_bar()
|
||||
|
||||
logger.info(f"Recording manually stopped at bar {actual_end}")
|
||||
|
||||
# Transition to cooldown for verification
|
||||
self._transition_to(RecordingState.COOLDOWN)
|
||||
|
||||
# Trigger verification
|
||||
self._verify_and_complete()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop recording: {e}")
|
||||
self._handle_error(e)
|
||||
return False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Update the state machine.
|
||||
|
||||
This method should be called regularly from Ableton's
|
||||
update_display() loop. It handles:
|
||||
- Pre-roll timing
|
||||
- Recording start trigger
|
||||
- Recording duration tracking
|
||||
- Automatic stop
|
||||
- Progress callbacks
|
||||
"""
|
||||
if self._state == RecordingState.IDLE:
|
||||
return
|
||||
|
||||
if self._state == RecordingState.ARMED:
|
||||
# Waiting for start() call
|
||||
return
|
||||
|
||||
if self._state == RecordingState.PRE_ROLL:
|
||||
self._handle_pre_roll()
|
||||
return
|
||||
|
||||
if self._state == RecordingState.RECORDING:
|
||||
self._handle_recording()
|
||||
return
|
||||
|
||||
if self._state == RecordingState.COOLDOWN:
|
||||
# Verification in progress, nothing to do
|
||||
return
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the recorder to IDLE state.
|
||||
|
||||
Clears all recording state. Can be called from any state.
|
||||
"""
|
||||
was_recording = self._state == RecordingState.RECORDING
|
||||
|
||||
if was_recording:
|
||||
try:
|
||||
self.song.stop_playing()
|
||||
self.song.arrangement_overdub = False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during reset cleanup: {e}")
|
||||
|
||||
old_state = self._state
|
||||
self._state = RecordingState.IDLE
|
||||
|
||||
# Clear all recording data
|
||||
self._config = None
|
||||
self._baseline = None
|
||||
self._new_clips = []
|
||||
self._new_clip_ids = set()
|
||||
self._target_start_bar = 0.0
|
||||
self._target_end_bar = 0.0
|
||||
self._pre_roll_target_bar = 0.0
|
||||
self._current_progress = 0.0
|
||||
|
||||
if old_state != RecordingState.IDLE:
|
||||
self._notify_state_change(old_state, RecordingState.IDLE)
|
||||
|
||||
logger.info("Recorder reset to IDLE")
|
||||
|
||||
def get_state(self) -> RecordingState:
|
||||
"""Get current recording state."""
|
||||
return self._state
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""
|
||||
Get recording progress from 0.0 to 1.0.
|
||||
|
||||
Returns:
|
||||
Progress value (0.0-1.0), or -1.0 if not recording
|
||||
"""
|
||||
if self._state not in (RecordingState.PRE_ROLL, RecordingState.RECORDING, RecordingState.COOLDOWN):
|
||||
return -1.0
|
||||
|
||||
return self._current_progress
|
||||
|
||||
def get_new_clips(self) -> List[str]:
|
||||
"""
|
||||
Get list of new clip IDs recorded in this session.
|
||||
|
||||
Returns:
|
||||
List of clip identifiers (track_index:clip_index format)
|
||||
"""
|
||||
return self._new_clips.copy()
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""
|
||||
Check if recorder is in an active state.
|
||||
|
||||
Returns:
|
||||
True if armed, pre-rolling, recording, or in cooldown
|
||||
"""
|
||||
return self._state in (
|
||||
RecordingState.ARMED,
|
||||
RecordingState.PRE_ROLL,
|
||||
RecordingState.RECORDING,
|
||||
RecordingState.COOLDOWN
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# PRIVATE METHODS - State Machine
|
||||
# ========================================================================
|
||||
|
||||
def _transition_to(self, new_state: RecordingState) -> None:
|
||||
"""Transition to a new state with notification."""
|
||||
old_state = self._state
|
||||
self._state = new_state
|
||||
self._state_entry_time = time.time()
|
||||
|
||||
logger.debug(f"State transition: {old_state.name} -> {new_state.name}")
|
||||
self._notify_state_change(old_state, new_state)
|
||||
|
||||
def _notify_state_change(self, old: RecordingState, new: RecordingState) -> None:
|
||||
"""Notify state change callback."""
|
||||
if self._config and self._config.on_state_change:
|
||||
try:
|
||||
self._config.on_state_change(old, new)
|
||||
except Exception as e:
|
||||
logger.warning(f"State change callback error: {e}")
|
||||
|
||||
def _notify_progress(self, progress: float) -> None:
|
||||
"""Notify progress callback (throttled)."""
|
||||
# Throttle to avoid flooding callbacks
|
||||
if abs(progress - self._last_progress_emit) < 0.01:
|
||||
return
|
||||
|
||||
self._last_progress_emit = progress
|
||||
|
||||
if self._config and self._config.on_progress:
|
||||
try:
|
||||
self._config.on_progress(progress)
|
||||
except Exception as e:
|
||||
logger.warning(f"Progress callback error: {e}")
|
||||
|
||||
def _handle_error(self, error: Exception) -> None:
|
||||
"""Handle error and transition to FAILED state."""
|
||||
logger.error(f"Recording error: {error}")
|
||||
|
||||
# Notify error callback
|
||||
if self._config and self._config.on_error:
|
||||
try:
|
||||
self._config.on_error(error)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error callback failed: {e}")
|
||||
|
||||
# Transition to failed state
|
||||
old_state = self._state
|
||||
self._state = RecordingState.FAILED
|
||||
self._notify_state_change(old_state, RecordingState.FAILED)
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
self.song.arrangement_overdub = False
|
||||
except:
|
||||
pass
|
||||
|
||||
def _handle_pre_roll(self) -> None:
|
||||
"""Handle pre-roll phase - wait until quantized start time."""
|
||||
current_bar = self._get_current_bar()
|
||||
|
||||
# Calculate progress through pre-roll (0.0 = start, 1.0 = recording starts)
|
||||
if self._config and self._config.pre_roll_bars > 0:
|
||||
pre_roll_start = self._pre_roll_target_bar - self._config.pre_roll_bars
|
||||
self._current_progress = (current_bar - pre_roll_start) / self._config.pre_roll_bars
|
||||
self._current_progress = max(0.0, min(0.99, self._current_progress))
|
||||
else:
|
||||
self._current_progress = 0.99
|
||||
|
||||
self._notify_progress(self._current_progress)
|
||||
|
||||
# Check if we've reached the target bar
|
||||
if current_bar >= self._pre_roll_target_bar:
|
||||
self._on_quantized_start()
|
||||
|
||||
def _handle_recording(self) -> None:
|
||||
"""Handle recording phase - track progress and auto-stop."""
|
||||
current_bar = self._get_current_bar()
|
||||
|
||||
# Calculate progress through recording
|
||||
recording_bars = self._target_end_bar - self._target_start_bar
|
||||
bars_elapsed = current_bar - self._target_start_bar
|
||||
self._current_progress = min(1.0, bars_elapsed / recording_bars)
|
||||
|
||||
self._notify_progress(self._current_progress)
|
||||
|
||||
# Check if recording should end
|
||||
if current_bar >= self._target_end_bar:
|
||||
self._on_recording_end()
|
||||
|
||||
# ========================================================================
|
||||
# PRIVATE METHODS - Recording Lifecycle
|
||||
# ========================================================================
|
||||
|
||||
def _verify_preconditions(self) -> None:
|
||||
"""
|
||||
Verify that recording can proceed.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If preconditions are not met
|
||||
"""
|
||||
if not self.song:
|
||||
raise RuntimeError("No song object available")
|
||||
|
||||
# Check that we have scenes to fire
|
||||
if not hasattr(self.song, 'scenes') or len(self.song.scenes) == 0:
|
||||
raise RuntimeError("No scenes available in project")
|
||||
|
||||
if self._config and self._config.scene_index >= len(self.song.scenes):
|
||||
raise RuntimeError(f"Scene index {self._config.scene_index} out of range")
|
||||
|
||||
# Check that we have tracks
|
||||
if not hasattr(self.song, 'tracks') or len(self.song.tracks) == 0:
|
||||
raise RuntimeError("No tracks available in project")
|
||||
|
||||
# Check arrangement_overdub can be set
|
||||
try:
|
||||
# Test setting and resetting
|
||||
original = self.song.arrangement_overdub
|
||||
self.song.arrangement_overdub = True
|
||||
self.song.arrangement_overdub = original
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot control arrangement_overdub: {e}")
|
||||
|
||||
logger.debug("Preconditions verified successfully")
|
||||
|
||||
def _capture_baseline(self) -> ArrangementBaseline:
|
||||
"""
|
||||
Capture current arrangement state for later comparison.
|
||||
|
||||
Returns:
|
||||
ArrangementBaseline with current state
|
||||
"""
|
||||
clip_ids = set()
|
||||
clip_positions = {}
|
||||
clip_count = 0
|
||||
|
||||
try:
|
||||
for track_idx, track in enumerate(self.song.tracks):
|
||||
if hasattr(track, 'arrangement_clips'):
|
||||
for clip in track.arrangement_clips:
|
||||
if clip:
|
||||
clip_id = f"{track_idx}:{clip.start_time}"
|
||||
clip_ids.add(clip_id)
|
||||
clip_positions[clip_id] = (clip.start_time, clip.end_time)
|
||||
clip_count += 1
|
||||
|
||||
# Get current arrangement length
|
||||
total_length = 0.0
|
||||
if hasattr(self.song, 'last_event_time'):
|
||||
total_length = float(self.song.last_event_time)
|
||||
|
||||
baseline = ArrangementBaseline(
|
||||
clip_count=clip_count,
|
||||
clip_ids=clip_ids,
|
||||
clip_positions=clip_positions,
|
||||
total_length=total_length,
|
||||
timestamp=time.time()
|
||||
)
|
||||
|
||||
logger.debug(f"Captured baseline: {clip_count} clips, length {total_length:.2f} beats")
|
||||
return baseline
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture complete baseline: {e}")
|
||||
return ArrangementBaseline(
|
||||
clip_count=0,
|
||||
clip_ids=set(),
|
||||
clip_positions={},
|
||||
total_length=0.0,
|
||||
timestamp=time.time()
|
||||
)
|
||||
|
||||
def _calculate_pre_roll(self) -> float:
|
||||
"""
|
||||
Calculate pre-roll time in beats until next bar boundary.
|
||||
|
||||
Returns:
|
||||
Number of beats until next bar
|
||||
"""
|
||||
current_time = self._get_current_song_time()
|
||||
beats_per_bar = 4.0 # Default 4/4
|
||||
|
||||
try:
|
||||
if hasattr(self.song, 'signature_numerator'):
|
||||
beats_per_bar = float(self.song.signature_numerator)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Find next bar boundary
|
||||
current_bar = current_time / beats_per_bar
|
||||
next_bar_num = int(current_bar) + 1
|
||||
next_bar_time = next_bar_num * beats_per_bar
|
||||
|
||||
pre_roll = next_bar_time - current_time
|
||||
return max(0.0, pre_roll)
|
||||
|
||||
def _on_quantized_start(self) -> None:
|
||||
"""
|
||||
Fire at exact bar boundary to start recording.
|
||||
|
||||
Fires the scene and begins recording.
|
||||
"""
|
||||
try:
|
||||
# Fire the scene
|
||||
if self._config:
|
||||
scene = self.song.scenes[self._config.scene_index]
|
||||
scene.fire()
|
||||
|
||||
# Ensure we're playing and overdubbing
|
||||
if not self.song.is_playing:
|
||||
self.song.start_playing()
|
||||
|
||||
self.song.arrangement_overdub = True
|
||||
|
||||
# Transition to recording
|
||||
self._transition_to(RecordingState.RECORDING)
|
||||
|
||||
logger.info(f"Recording started at bar {self._target_start_bar}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start recording at quantized time: {e}")
|
||||
self._handle_error(e)
|
||||
|
||||
def _on_recording_end(self) -> None:
|
||||
"""
|
||||
Stop recording and transition to verification.
|
||||
"""
|
||||
try:
|
||||
# Stop playback
|
||||
self.song.stop_playing()
|
||||
|
||||
# Disable overdub
|
||||
self.song.arrangement_overdub = False
|
||||
|
||||
logger.info(f"Recording ended at bar {self._target_end_bar}")
|
||||
|
||||
# Transition to cooldown
|
||||
self._transition_to(RecordingState.COOLDOWN)
|
||||
|
||||
# Trigger verification
|
||||
self._verify_and_complete()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error ending recording: {e}")
|
||||
self._handle_error(e)
|
||||
|
||||
def _verify_and_complete(self) -> None:
|
||||
"""
|
||||
Verify recording success and transition to COMPLETED or FAILED.
|
||||
"""
|
||||
try:
|
||||
success, new_clips = self._verify_recording_success()
|
||||
|
||||
if success:
|
||||
self._new_clips = new_clips
|
||||
self._transition_to(RecordingState.COMPLETED)
|
||||
|
||||
# Notify completion
|
||||
if self._config and self._config.on_completed:
|
||||
try:
|
||||
self._config.on_completed(new_clips)
|
||||
except Exception as e:
|
||||
logger.warning(f"Completion callback error: {e}")
|
||||
|
||||
logger.info(f"Recording completed successfully with {len(new_clips)} new clips")
|
||||
else:
|
||||
error = RuntimeError("Recording verification failed - no new clips detected")
|
||||
self._handle_error(error)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Verification failed: {e}")
|
||||
self._handle_error(e)
|
||||
|
||||
def _verify_recording_success(self) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Compare before/after state to verify recording succeeded.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, new_clip_ids: list)
|
||||
"""
|
||||
if not self._baseline:
|
||||
logger.warning("No baseline captured, cannot verify")
|
||||
return (True, []) # Assume success if we can't verify
|
||||
|
||||
try:
|
||||
# Capture current state
|
||||
current_count = 0
|
||||
current_ids = set()
|
||||
|
||||
for track_idx, track in enumerate(self.song.tracks):
|
||||
if hasattr(track, 'arrangement_clips'):
|
||||
for clip in track.arrangement_clips:
|
||||
if clip:
|
||||
clip_id = f"{track_idx}:{clip.start_time}"
|
||||
current_ids.add(clip_id)
|
||||
current_count += 1
|
||||
|
||||
# Find new clips
|
||||
new_clip_ids = current_ids - self._baseline.clip_ids
|
||||
|
||||
# Heuristic: at least one new clip should exist
|
||||
# But sometimes clips are merged or extended, so we also check count
|
||||
success = len(new_clip_ids) > 0 or current_count > self._baseline.clip_count
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Verification failed: {self._baseline.clip_count} -> {current_count} clips, "
|
||||
f"{len(new_clip_ids)} new")
|
||||
else:
|
||||
logger.debug(f"Verification passed: {len(new_clip_ids)} new clips")
|
||||
|
||||
return (success, list(new_clip_ids))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during verification: {e}")
|
||||
return (False, [])
|
||||
|
||||
# ========================================================================
|
||||
# PRIVATE METHODS - Utilities
|
||||
# ========================================================================
|
||||
|
||||
def _get_current_bar(self) -> float:
|
||||
"""
|
||||
Get current song position in bars (musical time).
|
||||
|
||||
Returns:
|
||||
Current bar number (can be fractional)
|
||||
"""
|
||||
try:
|
||||
beats = float(self.song.current_song_time)
|
||||
beats_per_bar = 4.0
|
||||
|
||||
if hasattr(self.song, 'signature_numerator'):
|
||||
beats_per_bar = float(self.song.signature_numerator)
|
||||
|
||||
return beats / beats_per_bar
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting current bar: {e}")
|
||||
return 0.0
|
||||
|
||||
def _get_current_song_time(self) -> float:
|
||||
"""
|
||||
Get current song position in beats.
|
||||
|
||||
Returns:
|
||||
Current position in beats
|
||||
"""
|
||||
try:
|
||||
return float(self.song.current_song_time)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting song time: {e}")
|
||||
return 0.0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
state = self._state.name
|
||||
progress = f"{self._current_progress:.1%}" if self._current_progress >= 0 else "N/A"
|
||||
return f"ArrangementRecorder(state={state}, progress={progress})"
|
||||
Reference in New Issue
Block a user