From 34c6651132b8751c6256f41a4b58562b889b0c4f Mon Sep 17 00:00:00 2001 From: melancholytron Date: Mon, 8 Sep 2025 21:43:21 -0500 Subject: [PATCH] Implement armed preset system and fix timing issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add armed preset switching that applies at pattern end (like note/scale changes) - Fix single-note pattern armed change detection - Add preset system safety mechanisms (timeout, force apply, clear armed buttons) - Fix delay timing to be absolute rather than relative to note speed - Comprehensive error handling and UI safety checks for preset controls 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- core/arpeggiator_engine.py | 107 +++++++++++++++++++++--- gui/main_window.py | 3 + gui/preset_controls.py | 162 +++++++++++++++++++++++++++++++------ 3 files changed, 235 insertions(+), 37 deletions(-) diff --git a/core/arpeggiator_engine.py b/core/arpeggiator_engine.py index c3a4473..93e0549 100644 --- a/core/arpeggiator_engine.py +++ b/core/arpeggiator_engine.py @@ -93,6 +93,15 @@ class ArpeggiatorEngine(QObject): self.armed_scale = None self.armed_pattern_type = None self.armed_channel_distribution = None + self.armed_preset_data = None + self.preset_apply_callback = None # Callback function for applying presets + + # Armed state timeout (30 seconds safety timeout) + self.armed_timeout = QTimer() + self.armed_timeout.setSingleShot(True) + self.armed_timeout.timeout.connect(self.armed_timeout_expired) + self.armed_timeout_duration = 30000 # 30 seconds in milliseconds + self.last_armed_apply_time = 0 # Track when armed changes were last applied # Pattern loop tracking self.pattern_loops_completed = 0 @@ -240,6 +249,57 @@ class ArpeggiatorEngine(QObject): self.armed_channel_distribution = None self.armed_state_changed.emit() + def arm_preset(self, preset_data: dict): + """Arm a preset for switching at pattern end""" + if preset_data: + self.armed_preset_data = preset_data + # Start timeout timer + self.armed_timeout.start(self.armed_timeout_duration) + self.armed_state_changed.emit() + + def clear_armed_preset(self): + """Clear armed preset""" + self.armed_preset_data = None + self.armed_state_changed.emit() + + def set_preset_apply_callback(self, callback): + """Set callback function for applying presets""" + self.preset_apply_callback = callback + + def has_armed_changes(self) -> bool: + """Check if any changes are armed""" + return any([ + self.armed_root_note is not None, + self.armed_scale is not None, + self.armed_pattern_type is not None, + self.armed_channel_distribution is not None, + self.armed_preset_data is not None + ]) + + def get_armed_preset_data(self): + """Get armed preset data""" + return self.armed_preset_data + + def force_apply_armed_changes(self): + """Force apply all armed changes immediately (emergency override)""" + self.apply_armed_changes() + self.last_armed_apply_time = time.time() # Update cooldown timer + + def clear_all_armed_changes(self): + """Clear all armed changes without applying them""" + self.armed_root_note = None + self.armed_scale = None + self.armed_pattern_type = None + self.armed_channel_distribution = None + self.armed_preset_data = None + self.armed_timeout.stop() # Stop timeout timer + self.armed_state_changed.emit() + + def armed_timeout_expired(self): + """Handle armed state timeout - clear all armed changes""" + print("Armed state timeout expired - clearing all armed changes") + self.clear_all_armed_changes() + def set_tempo(self, bpm: float): """Set tempo in BPM""" if 40 <= bpm <= 200: @@ -284,20 +344,14 @@ class ArpeggiatorEngine(QObject): self.step_duration = note_duration / beats_per_second def calculate_delay_step_duration(self): - """Calculate time between delay steps based on tempo and delay timing relative to note speed""" + """Calculate time between delay steps based on tempo and delay timing""" beats_per_second = self.tempo / 60.0 - # Get current note speed duration - current_note_duration = self.NOTE_SPEEDS[self.note_speed] - - # Get delay timing duration + # Get delay timing duration in beats delay_timing_duration = self.NOTE_SPEEDS[self.delay_timing] - # Calculate delay as multiple of note speed - delay_multiplier = delay_timing_duration / current_note_duration - - # Calculate actual delay step duration - self.delay_step_duration = (current_note_duration * delay_multiplier) / beats_per_second + # Calculate actual delay step duration in seconds + self.delay_step_duration = delay_timing_duration / beats_per_second def schedule_delays(self, channel: int, note: int, original_volume: int): """Schedule delay/echo repeats for a note""" @@ -723,9 +777,25 @@ class ArpeggiatorEngine(QObject): self.current_step += 1 # Check if pattern completed a full loop - if old_pattern_position != 0 and self.pattern_position == 0: + pattern_completed = False + if self.pattern_length == 1: + # For single-note patterns, consider every step a completion + pattern_completed = True self.pattern_loops_completed += 1 - self.apply_armed_changes() + elif old_pattern_position != 0 and self.pattern_position == 0: + # For multi-note patterns, completion is when we wrap back to 0 + pattern_completed = True + self.pattern_loops_completed += 1 + + if pattern_completed: + # Apply armed changes with cooldown to prevent rapid firing + current_time = time.time() + min_cooldown_time = 1.0 # Minimum 1 second between armed changes + + if (self.has_armed_changes() and + current_time - self.last_armed_apply_time >= min_cooldown_time): + self.apply_armed_changes() + self.last_armed_apply_time = current_time # Calculate next step time with swing base_time = self.next_step_time + self.step_duration @@ -768,8 +838,21 @@ class ArpeggiatorEngine(QObject): self.armed_channel_distribution = None changes_applied = True + # Apply armed preset + if self.armed_preset_data is not None and self.preset_apply_callback: + try: + self.preset_apply_callback(self.armed_preset_data) + self.armed_preset_data = None + changes_applied = True + except Exception as e: + print(f"Error applying armed preset: {e}") + # Clear the armed preset to prevent lockup + self.armed_preset_data = None + changes_applied = True + # If any changes were applied, regenerate pattern and emit signal if changes_applied: + self.armed_timeout.stop() # Stop timeout timer since changes were applied self.regenerate_pattern() self.armed_state_changed.emit() diff --git a/gui/main_window.py b/gui/main_window.py index 926d7d8..7b1f2a9 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -97,6 +97,9 @@ class MainWindow(QMainWindow): self.preset_controls = PresetControls(self.arpeggiator, self.channel_manager, self.volume_engine) tab_widget.addTab(self.preset_controls, "Presets") + # Set up preset callback for armed preset system + self.arpeggiator.set_preset_apply_callback(self.preset_controls.apply_preset_settings) + # Status display at bottom status_frame = self.create_status_display() main_layout.addWidget(status_frame) diff --git a/gui/preset_controls.py b/gui/preset_controls.py index fa77f62..302ac87 100644 --- a/gui/preset_controls.py +++ b/gui/preset_controls.py @@ -31,6 +31,10 @@ class PresetControls(QWidget): self.setup_ui() self.load_presets_from_directory() + self.load_first_preset_on_startup() + + # Connect to armed state changes + self.arpeggiator.armed_state_changed.connect(self.on_armed_state_changed) def setup_ui(self): """Set up the user interface""" @@ -111,6 +115,17 @@ class PresetControls(QWidget): self.duplicate_button.clicked.connect(self.duplicate_selected_preset) layout.addWidget(self.duplicate_button, 2, 1) + # Safety controls + self.force_apply_button = QPushButton("Force Apply Armed") + self.force_apply_button.clicked.connect(self.force_apply_armed) + self.force_apply_button.setStyleSheet("color: #ffaa00; font-weight: bold;") + layout.addWidget(self.force_apply_button, 3, 0) + + self.clear_armed_button = QPushButton("Clear Armed") + self.clear_armed_button.clicked.connect(self.clear_armed) + self.clear_armed_button.setStyleSheet("color: #aa6666;") + layout.addWidget(self.clear_armed_button, 3, 1) + return group def create_file_operations(self) -> QGroupBox: @@ -147,7 +162,13 @@ class PresetControls(QWidget): "gate": self.arpeggiator.gate, "swing": self.arpeggiator.swing, "velocity": self.arpeggiator.velocity, - "tempo": self.arpeggiator.tempo + "tempo": self.arpeggiator.tempo, + "pattern_length": getattr(self.arpeggiator, 'pattern_length', 8), + "channel_distribution": self.arpeggiator.channel_distribution, + "delay_enabled": self.arpeggiator.delay_enabled, + "delay_length": self.arpeggiator.delay_length, + "delay_timing": self.arpeggiator.delay_timing, + "delay_fade": self.arpeggiator.delay_fade }, # Channel settings @@ -173,6 +194,12 @@ class PresetControls(QWidget): def apply_preset_settings(self, preset: dict): """Apply preset settings to the system""" try: + # Find the preset name for this data (for tracking current preset) + preset_name = None + for name, data in self.presets.items(): + if data == preset: + preset_name = name + break # Apply arpeggiator settings arp_settings = preset.get("arpeggiator", {}) self.arpeggiator.set_root_note(arp_settings.get("root_note", 60)) @@ -185,6 +212,20 @@ class PresetControls(QWidget): self.arpeggiator.set_velocity(arp_settings.get("velocity", 80)) self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0)) + # Apply pattern length if available + if "pattern_length" in arp_settings: + if hasattr(self.arpeggiator, 'set_pattern_length'): + self.arpeggiator.set_pattern_length(arp_settings["pattern_length"]) + + # Apply channel distribution + self.arpeggiator.set_channel_distribution(arp_settings.get("channel_distribution", "up")) + + # Apply delay settings + self.arpeggiator.set_delay_enabled(arp_settings.get("delay_enabled", False)) + self.arpeggiator.set_delay_length(arp_settings.get("delay_length", 3)) + self.arpeggiator.set_delay_timing(arp_settings.get("delay_timing", "1/4")) + self.arpeggiator.set_delay_fade(arp_settings.get("delay_fade", 0.3)) + # Apply channel settings channel_settings = preset.get("channels", {}) self.channel_manager.set_active_synth_count( @@ -220,6 +261,13 @@ class PresetControls(QWidget): for channel_str, range_tuple in vel_ranges.items(): channel = int(channel_str) self.volume_engine.set_velocity_range(channel, range_tuple[0], range_tuple[1]) + + # Update UI if preset was found + if preset_name: + self.current_preset = preset_name + self.current_preset_label.setText(preset_name) + # Update colors without refreshing the entire list + self.update_preset_list_colors() except Exception as e: QMessageBox.warning(self, "Preset Error", f"Error applying preset: {str(e)}") @@ -227,16 +275,28 @@ class PresetControls(QWidget): @pyqtSlot(QListWidgetItem) def on_preset_selected(self, item): """Handle preset selection""" - preset_name = item.text() - - # Enable/disable buttons based on selection - has_selection = preset_name is not None - self.load_button.setEnabled(has_selection) - self.update_button.setEnabled(has_selection) - self.delete_button.setEnabled(has_selection) - self.rename_button.setEnabled(has_selection) - self.duplicate_button.setEnabled(has_selection) - self.export_button.setEnabled(has_selection) + try: + if not item: + return + + preset_name = item.text() + + # Enable/disable buttons based on selection + has_selection = preset_name is not None + self.load_button.setEnabled(has_selection) + self.update_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + self.rename_button.setEnabled(has_selection) + self.duplicate_button.setEnabled(has_selection) + self.export_button.setEnabled(has_selection) + except RuntimeError: + # Item was deleted, disable all buttons + self.load_button.setEnabled(False) + self.update_button.setEnabled(False) + self.delete_button.setEnabled(False) + self.rename_button.setEnabled(False) + self.duplicate_button.setEnabled(False) + self.export_button.setEnabled(False) @pyqtSlot(QListWidgetItem) def on_preset_double_clicked(self, item): @@ -245,23 +305,30 @@ class PresetControls(QWidget): @pyqtSlot() def load_selected_preset(self): - """Load the selected preset""" + """Arm the selected preset for loading at pattern end""" current_item = self.preset_list.currentItem() if not current_item: return - preset_name = current_item.text() - if preset_name in self.presets: - self.apply_preset_settings(self.presets[preset_name]) - self.current_preset = preset_name - self.current_preset_label.setText(preset_name) - - # Visual feedback - current_item.setBackground(Qt.darkGreen) - for i in range(self.preset_list.count()): - item = self.preset_list.item(i) - if item != current_item: - item.setBackground(Qt.transparent) + try: + preset_name = current_item.text() + if preset_name in self.presets: + # Arm the preset instead of immediately applying it + self.arpeggiator.arm_preset(self.presets[preset_name]) + + # Visual feedback - orange for armed preset + current_item.setBackground(Qt.darkYellow) + for i in range(self.preset_list.count()): + item = self.preset_list.item(i) + if item and item != current_item: + # Keep current preset green, others transparent + if item.text() == self.current_preset: + item.setBackground(Qt.darkGreen) + else: + item.setBackground(Qt.transparent) + except RuntimeError: + # Item was deleted, ignore + pass @pyqtSlot() def save_new_preset(self): @@ -470,6 +537,18 @@ class PresetControls(QWidget): self.refresh_preset_list() + def load_first_preset_on_startup(self): + """Automatically load the first preset on startup if available""" + if self.preset_list.count() > 0: + # Get the first item (presets are sorted alphabetically) + first_item = self.preset_list.item(0) + if first_item: + preset_name = first_item.text() + if preset_name in self.presets: + # Apply immediately on startup (not armed) + self.apply_preset_settings(self.presets[preset_name]) + self.preset_list.setCurrentItem(first_item) + def save_preset_to_file(self, name: str, preset_data: dict): """Save a preset to file""" file_path = os.path.join(self.presets_directory, f"{name}.json") @@ -488,6 +567,21 @@ class PresetControls(QWidget): item.setBackground(Qt.darkGreen) self.preset_list.addItem(item) + def update_preset_list_colors(self): + """Update preset list colors without recreating items""" + try: + for i in range(self.preset_list.count()): + item = self.preset_list.item(i) + if item and not item.isHidden(): + preset_name = item.text() + if preset_name == self.current_preset: + item.setBackground(Qt.darkGreen) + else: + item.setBackground(Qt.transparent) + except RuntimeError: + # If items were deleted, just refresh the whole list + self.refresh_preset_list() + def get_current_timestamp(self) -> str: """Get current timestamp string""" from datetime import datetime @@ -507,4 +601,22 @@ class PresetControls(QWidget): if self.preset_list.currentItem(): self.update_selected_preset() else: - self.save_new_preset() \ No newline at end of file + self.save_new_preset() + + @pyqtSlot() + def force_apply_armed(self): + """Force apply any armed changes immediately""" + self.arpeggiator.force_apply_armed_changes() + + @pyqtSlot() + def clear_armed(self): + """Clear all armed changes without applying them""" + self.arpeggiator.clear_all_armed_changes() + # Update UI to remove orange highlighting + self.update_preset_list_colors() + + @pyqtSlot() + def on_armed_state_changed(self): + """Handle armed state changes""" + # Update UI colors when armed state changes + self.update_preset_list_colors() \ No newline at end of file