""" Preset Controls GUI Interface for saving, loading, and managing presets. """ from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QListWidget, QPushButton, QLineEdit, QLabel, QFileDialog, QMessageBox, QListWidgetItem, QInputDialog, QFrame, QSpinBox, QComboBox, QCheckBox, QSplitter) from PyQt5.QtCore import Qt, pyqtSlot, QTimer import json import os import random class PresetControls(QWidget): """Control panel for preset management""" def __init__(self, arpeggiator, channel_manager, volume_engine): super().__init__() self.arpeggiator = arpeggiator self.channel_manager = channel_manager self.volume_engine = volume_engine # Preset storage self.presets = {} self.current_preset = None self.presets_directory = "presets" # Preset group functionality self.preset_group = [] # List of preset names in the group self.group_enabled = False self.group_current_index = 0 self.group_loop_count = 1 # How many times to play each preset self.group_current_loops = 0 # Current loop count for active preset self.group_order = "in_order" # "in_order" or "random" self.group_pattern_note_count = 0 # Count notes played in current pattern loop self.group_timer = QTimer() self.group_timer.setSingleShot(True) self.group_timer.timeout.connect(self.advance_group_preset) # Ensure presets directory exists os.makedirs(self.presets_directory, exist_ok=True) 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) # Connect to playing state changes for group cycling self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed) self.arpeggiator.pattern_step.connect(self.on_pattern_step) def apply_scaling(self, scale_factor): """Apply scaling to preset controls (placeholder for future implementation)""" # For now, preset controls don't need special scaling # Individual buttons already use expanding size policies from their styling pass def setup_ui(self): """Set up the user interface""" layout = QVBoxLayout(self) # Create splitter to divide preset management and preset groups splitter = QSplitter(Qt.Horizontal) layout.addWidget(splitter) # Left side: Original preset management preset_widget = QWidget() preset_layout = QVBoxLayout(preset_widget) preset_group = self.create_preset_list() preset_layout.addWidget(preset_group) operations_group = self.create_operations() preset_layout.addWidget(operations_group) file_group = self.create_file_operations() preset_layout.addWidget(file_group) splitter.addWidget(preset_widget) # Right side: Preset group functionality group_widget = QWidget() group_layout = QVBoxLayout(group_widget) preset_group_section = self.create_preset_group_section() group_layout.addWidget(preset_group_section) splitter.addWidget(group_widget) # Set equal sizes for both sides splitter.setSizes([400, 400]) def create_preset_list(self) -> QGroupBox: """Create preset list display""" group = QGroupBox("Presets") layout = QVBoxLayout(group) self.preset_list = QListWidget() self.preset_list.setMaximumHeight(200) self.preset_list.itemClicked.connect(self.on_preset_selected) self.preset_list.itemDoubleClicked.connect(self.on_preset_double_clicked) layout.addWidget(self.preset_list) # Current preset indicator current_layout = QHBoxLayout() current_layout.addWidget(QLabel("Current:")) self.current_preset_label = QLabel("None") self.current_preset_label.setStyleSheet("font-weight: bold; color: #00aa00;") current_layout.addWidget(self.current_preset_label) current_layout.addStretch() layout.addLayout(current_layout) return group def create_operations(self) -> QGroupBox: """Create preset operation buttons""" group = QGroupBox("Operations") layout = QGridLayout(group) # Load preset self.load_button = QPushButton("Load Preset") self.load_button.setEnabled(False) self.load_button.clicked.connect(self.load_selected_preset) self.load_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.load_button, 0, 0) # Save current as new preset self.save_new_button = QPushButton("Save as New...") self.save_new_button.clicked.connect(self.save_new_preset) self.save_new_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.save_new_button, 0, 1) # Update selected preset self.update_button = QPushButton("Update Selected") self.update_button.setEnabled(False) self.update_button.clicked.connect(self.update_selected_preset) self.update_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.update_button, 1, 0) # Delete preset self.delete_button = QPushButton("Delete Selected") self.delete_button.setEnabled(False) self.delete_button.clicked.connect(self.delete_selected_preset) self.delete_button.setStyleSheet("background: #5a2d2d; color: #ff9999; font-weight: bold; font-size: 12px; border: 1px solid #aa5555; padding: 5px 10px;") layout.addWidget(self.delete_button, 1, 1) # Rename preset self.rename_button = QPushButton("Rename Selected") self.rename_button.setEnabled(False) self.rename_button.clicked.connect(self.rename_selected_preset) self.rename_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.rename_button, 2, 0) # Duplicate preset self.duplicate_button = QPushButton("Duplicate Selected") self.duplicate_button.setEnabled(False) self.duplicate_button.clicked.connect(self.duplicate_selected_preset) self.duplicate_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") 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("background: #5a4d2d; color: #ffcc66; font-weight: bold; font-size: 12px; border: 1px solid #8a7a4a; padding: 5px 10px;") 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("background: #5a2d2d; color: #ff9999; font-weight: bold; font-size: 12px; border: 1px solid #aa5555; padding: 5px 10px;") layout.addWidget(self.clear_armed_button, 3, 1) return group def create_file_operations(self) -> QGroupBox: """Create file operation buttons""" group = QGroupBox("File Operations") layout = QHBoxLayout(group) # Import preset self.import_button = QPushButton("Import Preset...") self.import_button.clicked.connect(self.import_preset) self.import_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.import_button) # Export preset self.export_button = QPushButton("Export Selected...") self.export_button.setEnabled(False) self.export_button.clicked.connect(self.export_selected_preset) self.export_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") layout.addWidget(self.export_button) return group def create_preset_group_section(self) -> QGroupBox: """Create preset group functionality section""" group = QGroupBox("Preset Groups") layout = QVBoxLayout(group) # Enable/Disable group cycling self.group_enable_checkbox = QCheckBox("Enable Group Cycling") self.group_enable_checkbox.stateChanged.connect(self.on_group_enable_changed) layout.addWidget(self.group_enable_checkbox) # Current group status status_layout = QHBoxLayout() status_layout.addWidget(QLabel("Status:")) self.group_status_label = QLabel("Inactive") self.group_status_label.setStyleSheet("color: #888888;") status_layout.addWidget(self.group_status_label) status_layout.addStretch() layout.addLayout(status_layout) # Group preset list layout.addWidget(QLabel("Presets in Group:")) self.group_preset_list = QListWidget() self.group_preset_list.setMaximumHeight(150) self.group_preset_list.setDragDropMode(QListWidget.InternalMove) # Allow reordering layout.addWidget(self.group_preset_list) # Add/Remove buttons group_buttons_layout = QHBoxLayout() self.add_to_group_button = QPushButton("Add Selected →") self.add_to_group_button.setEnabled(False) self.add_to_group_button.clicked.connect(self.add_preset_to_group) self.add_to_group_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") group_buttons_layout.addWidget(self.add_to_group_button) self.remove_from_group_button = QPushButton("← Remove") self.remove_from_group_button.setEnabled(False) self.remove_from_group_button.clicked.connect(self.remove_preset_from_group) self.remove_from_group_button.setStyleSheet("background: #5a2d2d; color: #ff9999; font-weight: bold; font-size: 12px; border: 1px solid #aa5555; padding: 5px 10px;") group_buttons_layout.addWidget(self.remove_from_group_button) layout.addLayout(group_buttons_layout) # Clear group button self.clear_group_button = QPushButton("Clear Group") self.clear_group_button.clicked.connect(self.clear_preset_group) self.clear_group_button.setStyleSheet("background: #5a2d2d; color: #ff9999; font-weight: bold; font-size: 12px; border: 1px solid #aa5555; padding: 5px 10px;") layout.addWidget(self.clear_group_button) # Group settings settings_frame = QFrame() settings_frame.setFrameStyle(QFrame.Box) settings_layout = QGridLayout(settings_frame) # Loop count settings_layout.addWidget(QLabel("Loop Count:"), 0, 0) self.loop_count_spinbox = QSpinBox() self.loop_count_spinbox.setRange(1, 99) self.loop_count_spinbox.setValue(1) self.loop_count_spinbox.valueChanged.connect(self.on_loop_count_changed) settings_layout.addWidget(self.loop_count_spinbox, 0, 1) # Preset order settings_layout.addWidget(QLabel("Order:"), 1, 0) self.order_combo = QComboBox() self.order_combo.addItems(["In Order", "Random"]) self.order_combo.currentTextChanged.connect(self.on_order_changed) settings_layout.addWidget(self.order_combo, 1, 1) layout.addWidget(settings_frame) # Manual controls manual_layout = QHBoxLayout() self.prev_preset_button = QPushButton("◀ Previous") self.prev_preset_button.setEnabled(False) self.prev_preset_button.clicked.connect(self.goto_previous_preset) self.prev_preset_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") manual_layout.addWidget(self.prev_preset_button) self.next_preset_button = QPushButton("Next ▶") self.next_preset_button.setEnabled(False) self.next_preset_button.clicked.connect(self.goto_next_preset) self.next_preset_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") manual_layout.addWidget(self.next_preset_button) # TEMPORARY DEBUG BUTTON self.debug_advance_button = QPushButton("DEBUG: Force Advance") self.debug_advance_button.clicked.connect(self.advance_group_preset) self.debug_advance_button.setStyleSheet("background: #5a2d5a; color: #ffaaff; font-weight: bold; font-size: 12px; border: 1px solid #8a4a8a; padding: 5px 10px;") manual_layout.addWidget(self.debug_advance_button) layout.addLayout(manual_layout) # Master file controls master_frame = QFrame() master_frame.setFrameStyle(QFrame.Box) master_layout = QGridLayout(master_frame) master_layout.addWidget(QLabel("Master Files:"), 0, 0, 1, 2) self.save_master_button = QPushButton("Save Master...") self.save_master_button.clicked.connect(self.save_master_file) self.save_master_button.setStyleSheet("background: #2d5a2d; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #4a8a4a; padding: 5px 10px;") master_layout.addWidget(self.save_master_button, 1, 0) self.load_master_button = QPushButton("Load Master...") self.load_master_button.clicked.connect(self.load_master_file) self.load_master_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #555555; padding: 5px 10px;") master_layout.addWidget(self.load_master_button, 1, 1) layout.addWidget(master_frame) # Connect group list selection self.group_preset_list.itemSelectionChanged.connect(self.on_group_selection_changed) return group def capture_current_settings(self) -> dict: """Capture current settings into a preset dictionary""" preset = { "version": "1.0", "timestamp": None, # Will be set when saving # Arpeggiator settings "arpeggiator": { "root_note": self.arpeggiator.root_note, "scale": self.arpeggiator.scale, "scale_note_start": self.arpeggiator.scale_note_start, "pattern_type": self.arpeggiator.pattern_type, "octave_range": self.arpeggiator.octave_range, "note_speed": self.arpeggiator.note_speed, "gate": self.arpeggiator.gate, "swing": self.arpeggiator.swing, "velocity": self.arpeggiator.velocity, "tempo": self.arpeggiator.tempo, "user_pattern_length": getattr(self.arpeggiator, 'user_pattern_length', 8), "note_limit": getattr(self.arpeggiator, 'note_limit', 7), "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 "channels": { "active_synth_count": self.channel_manager.active_synth_count, "channel_instruments": self.channel_manager.channel_instruments.copy() }, # Volume pattern settings "volume_patterns": { "current_pattern": self.volume_engine.current_pattern, "pattern_speed": self.volume_engine.pattern_speed, "pattern_intensity": self.volume_engine.pattern_intensity, "global_volume_range": self.volume_engine.global_volume_range, "global_velocity_range": self.volume_engine.global_velocity_range, "channel_volume_ranges": self.volume_engine.channel_volume_ranges.copy(), "velocity_ranges": self.volume_engine.velocity_ranges.copy() } } return preset 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)) self.arpeggiator.set_scale(arp_settings.get("scale", "major")) self.arpeggiator.set_scale_note_start(arp_settings.get("scale_note_start", 0)) self.arpeggiator.set_pattern_type(arp_settings.get("pattern_type", "up")) self.arpeggiator.set_octave_range(arp_settings.get("octave_range", 1)) self.arpeggiator.set_note_speed(arp_settings.get("note_speed", "1/8")) self.arpeggiator.set_gate(arp_settings.get("gate", 1.0)) self.arpeggiator.set_swing(arp_settings.get("swing", 0.0)) self.arpeggiator.set_velocity(arp_settings.get("velocity", 80)) self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0)) # Apply user pattern length (check both old and new names for compatibility) pattern_length = arp_settings.get("user_pattern_length") or arp_settings.get("pattern_length", 8) if hasattr(self.arpeggiator, 'set_user_pattern_length'): self.arpeggiator.set_user_pattern_length(pattern_length) elif hasattr(self.arpeggiator, 'set_pattern_length'): self.arpeggiator.set_pattern_length(pattern_length) # Apply note limit note_limit = arp_settings.get("note_limit", 7) if hasattr(self.arpeggiator, 'set_note_limit'): self.arpeggiator.set_note_limit(note_limit) # 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( channel_settings.get("active_synth_count", 8) ) # Apply instruments instruments = channel_settings.get("channel_instruments", {}) for channel_str, program in instruments.items(): channel = int(channel_str) self.channel_manager.set_channel_instrument(channel, program) # Apply volume pattern settings volume_settings = preset.get("volume_patterns", {}) self.volume_engine.set_pattern(volume_settings.get("current_pattern", "static")) self.volume_engine.set_pattern_speed(volume_settings.get("pattern_speed", 1.0)) self.volume_engine.set_pattern_intensity(volume_settings.get("pattern_intensity", 1.0)) # Apply global ranges global_vol = volume_settings.get("global_volume_range", (0.2, 1.0)) global_vel = volume_settings.get("global_velocity_range", (40, 127)) self.volume_engine.set_global_ranges( global_vol[0], global_vol[1], global_vel[0], global_vel[1] ) # Apply individual channel ranges ch_vol_ranges = volume_settings.get("channel_volume_ranges", {}) for channel_str, range_tuple in ch_vol_ranges.items(): channel = int(channel_str) self.volume_engine.set_channel_volume_range(channel, range_tuple[0], range_tuple[1]) vel_ranges = volume_settings.get("velocity_ranges", {}) 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() # Emit signal so GUI controls update self.arpeggiator.settings_changed.emit() except Exception as e: QMessageBox.warning(self, "Preset Error", f"Error applying preset: {str(e)}") @pyqtSlot(QListWidgetItem) def on_preset_selected(self, item): """Handle preset 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) # Update group UI state (for add button enablement) self.update_group_ui_state() 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): """Handle preset double-click (load preset)""" self.load_selected_preset() @pyqtSlot() def load_selected_preset(self): """Arm the selected preset for loading at pattern end""" current_item = self.preset_list.currentItem() if not current_item: return 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): """Save current settings as a new preset""" name, ok = QInputDialog.getText(self, "New Preset", "Enter preset name:") if ok and name: if name in self.presets: reply = QMessageBox.question( self, "Overwrite Preset", f"Preset '{name}' already exists. Overwrite?", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return # Capture current settings preset_data = self.capture_current_settings() preset_data["timestamp"] = self.get_current_timestamp() # Save preset self.presets[name] = preset_data self.save_preset_to_file(name, preset_data) # Update list self.refresh_preset_list() # Select the new preset items = self.preset_list.findItems(name, Qt.MatchExactly) if items: self.preset_list.setCurrentItem(items[0]) @pyqtSlot() def update_selected_preset(self): """Update the selected preset with current settings""" current_item = self.preset_list.currentItem() if not current_item: return preset_name = current_item.text() reply = QMessageBox.question( self, "Update Preset", f"Update preset '{preset_name}' with current settings?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: preset_data = self.capture_current_settings() preset_data["timestamp"] = self.get_current_timestamp() self.presets[preset_name] = preset_data self.save_preset_to_file(preset_name, preset_data) @pyqtSlot() def delete_selected_preset(self): """Delete the selected preset""" current_item = self.preset_list.currentItem() if not current_item: return preset_name = current_item.text() reply = QMessageBox.question( self, "Delete Preset", f"Delete preset '{preset_name}'? This cannot be undone.", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # Remove from memory and file if preset_name in self.presets: del self.presets[preset_name] preset_file = os.path.join(self.presets_directory, f"{preset_name}.json") if os.path.exists(preset_file): os.remove(preset_file) # Update list self.refresh_preset_list() # Clear current if it was deleted if self.current_preset == preset_name: self.current_preset = None self.current_preset_label.setText("None") @pyqtSlot() def rename_selected_preset(self): """Rename the selected preset""" current_item = self.preset_list.currentItem() if not current_item: return old_name = current_item.text() new_name, ok = QInputDialog.getText(self, "Rename Preset", "Enter new name:", text=old_name) if ok and new_name and new_name != old_name: if new_name in self.presets: QMessageBox.warning(self, "Rename Error", f"Preset '{new_name}' already exists.") return # Move preset data self.presets[new_name] = self.presets[old_name] del self.presets[old_name] # Handle files old_file = os.path.join(self.presets_directory, f"{old_name}.json") new_file = os.path.join(self.presets_directory, f"{new_name}.json") if os.path.exists(old_file): os.rename(old_file, new_file) # Update current preset reference if self.current_preset == old_name: self.current_preset = new_name self.current_preset_label.setText(new_name) # Refresh list self.refresh_preset_list() @pyqtSlot() def duplicate_selected_preset(self): """Duplicate the selected preset""" current_item = self.preset_list.currentItem() if not current_item: return source_name = current_item.text() new_name, ok = QInputDialog.getText(self, "Duplicate Preset", "Enter name for copy:", text=f"{source_name} Copy") if ok and new_name: if new_name in self.presets: QMessageBox.warning(self, "Duplicate Error", f"Preset '{new_name}' already exists.") return # Copy preset data self.presets[new_name] = self.presets[source_name].copy() self.save_preset_to_file(new_name, self.presets[new_name]) # Refresh list and select new preset self.refresh_preset_list() items = self.preset_list.findItems(new_name, Qt.MatchExactly) if items: self.preset_list.setCurrentItem(items[0]) @pyqtSlot() def import_preset(self): """Import a preset from file""" file_path, _ = QFileDialog.getOpenFileName( self, "Import Preset", "", "JSON Files (*.json);;All Files (*)" ) if file_path: try: with open(file_path, 'r') as f: preset_data = json.load(f) # Get name from user default_name = os.path.splitext(os.path.basename(file_path))[0] name, ok = QInputDialog.getText(self, "Import Preset", "Preset name:", text=default_name) if ok and name: self.presets[name] = preset_data self.save_preset_to_file(name, preset_data) self.refresh_preset_list() except Exception as e: QMessageBox.critical(self, "Import Error", f"Error importing preset: {str(e)}") @pyqtSlot() def export_selected_preset(self): """Export the selected preset to file""" current_item = self.preset_list.currentItem() if not current_item: return preset_name = current_item.text() file_path, _ = QFileDialog.getSaveFileName( self, "Export Preset", f"{preset_name}.json", "JSON Files (*.json);;All Files (*)" ) if file_path: try: with open(file_path, 'w') as f: json.dump(self.presets[preset_name], f, indent=2) except Exception as e: QMessageBox.critical(self, "Export Error", f"Error exporting preset: {str(e)}") def load_presets_from_directory(self): """Load all presets from the presets directory""" if not os.path.exists(self.presets_directory): return for filename in os.listdir(self.presets_directory): if filename.endswith('.json'): preset_name = os.path.splitext(filename)[0] file_path = os.path.join(self.presets_directory, filename) try: with open(file_path, 'r') as f: preset_data = json.load(f) self.presets[preset_name] = preset_data except Exception as e: print(f"Error loading preset {filename}: {e}") 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") try: with open(file_path, 'w') as f: json.dump(preset_data, f, indent=2) except Exception as e: QMessageBox.critical(self, "Save Error", f"Error saving preset: {str(e)}") def refresh_preset_list(self): """Refresh the preset list display""" self.preset_list.clear() for name in sorted(self.presets.keys()): item = QListWidgetItem(name) if name == self.current_preset: 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 return datetime.now().isoformat() def new_preset(self): """Create a new preset (for menu action)""" self.save_new_preset() def load_preset(self): """Load preset (for menu action)""" if self.preset_list.currentItem(): self.load_selected_preset() def save_preset(self): """Save preset (for menu action)""" if self.preset_list.currentItem(): self.update_selected_preset() else: 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() # ======= PRESET GROUP FUNCTIONALITY ======= def on_group_enable_changed(self, state): """Handle group cycling enable/disable""" self.group_enabled = state == Qt.Checked print(f"DEBUG: Group cycling enabled changed to: {self.group_enabled} (state={state})") if self.group_enabled and len(self.preset_group) > 0: print("DEBUG: Calling start_group_cycling") self.start_group_cycling() else: print("DEBUG: Calling stop_group_cycling") self.stop_group_cycling() self.update_group_ui_state() def on_group_selection_changed(self): """Handle selection change in group preset list""" self.remove_from_group_button.setEnabled(len(self.group_preset_list.selectedItems()) > 0) def on_loop_count_changed(self, value): """Handle loop count change""" print(f"DEBUG: Loop count changed from {self.group_loop_count} to {value}") self.group_loop_count = value # Only reset current loops if we're not actively cycling if not self.group_enabled or not self.group_timer.isActive(): print("DEBUG: Resetting current loops (not actively cycling)") self.group_current_loops = 0 else: print("DEBUG: NOT resetting current loops (actively cycling)") def on_order_changed(self, text): """Handle order change""" self.group_order = "random" if text == "Random" else "in_order" # If random, shuffle the current group if self.group_order == "random" and len(self.preset_group) > 1: # Create a new random order without changing the original list pass # We'll handle randomization in advance_group_preset def add_preset_to_group(self): """Add selected preset to the group""" current_item = self.preset_list.currentItem() if current_item: preset_name = current_item.text() if preset_name not in self.preset_group: self.preset_group.append(preset_name) self.update_group_preset_list() self.update_group_ui_state() def remove_preset_from_group(self): """Remove selected preset from the group""" current_item = self.group_preset_list.currentItem() if current_item: preset_name = current_item.text() if preset_name in self.preset_group: self.preset_group.remove(preset_name) self.update_group_preset_list() self.update_group_ui_state() def clear_preset_group(self): """Clear all presets from the group""" self.preset_group.clear() self.stop_group_cycling() self.update_group_preset_list() self.update_group_ui_state() def update_group_preset_list(self): """Update the group preset list display""" self.group_preset_list.clear() for preset_name in self.preset_group: item = QListWidgetItem(preset_name) # Highlight current preset in group if self.group_enabled and preset_name == self.get_current_group_preset(): item.setBackground(Qt.darkBlue) self.group_preset_list.addItem(item) def update_group_ui_state(self): """Update group UI elements based on current state""" has_presets = len(self.preset_group) > 0 is_active = self.group_enabled and has_presets # Update status if is_active: current_preset = self.get_current_group_preset() # Show note progress instead of loop progress pattern_length = self.arpeggiator.user_pattern_length if hasattr(self.arpeggiator, 'user_pattern_length') else 0 total_notes_needed = pattern_length * self.group_loop_count progress_text = f" (Note {self.group_pattern_note_count}/{total_notes_needed})" self.group_status_label.setText(f"Active: {current_preset}{progress_text}") self.group_status_label.setStyleSheet("color: #00aa00; font-weight: bold;") elif self.group_enabled: self.group_status_label.setText("Enabled - No Presets") self.group_status_label.setStyleSheet("color: #aaaa00;") else: self.group_status_label.setText("Inactive") self.group_status_label.setStyleSheet("color: #888888;") # Update button states self.prev_preset_button.setEnabled(is_active) self.next_preset_button.setEnabled(is_active) # Update add button based on selection current_item = self.preset_list.currentItem() can_add = (current_item is not None and current_item.text() not in self.preset_group) self.add_to_group_button.setEnabled(can_add) def start_group_cycling(self): """Start automatic group cycling""" print(f"DEBUG: start_group_cycling called with {len(self.preset_group)} presets in group") if len(self.preset_group) > 0: # Only reset position if we're not already cycling if not self.group_timer.isActive(): print("DEBUG: Resetting group position (first time start)") self.group_current_index = 0 self.group_current_loops = 0 # Load first preset first_preset = self.preset_group[0] print(f"DEBUG: Loading first preset: '{first_preset}'") if first_preset in self.presets: self.apply_preset_settings(self.presets[first_preset]) self.current_preset = first_preset print(f"DEBUG: Successfully loaded first preset: '{first_preset}'") else: print(f"DEBUG: ERROR - First preset '{first_preset}' not found in presets!") else: print("DEBUG: Group cycling already active, not resetting position") # Initialize pattern note counter self.group_pattern_note_count = 0 self.update_group_preset_list() print("DEBUG: Group cycling started - waiting for arpeggiator to start playing") def stop_group_cycling(self): """Stop automatic group cycling""" self.group_timer.stop() # Disconnect from arpeggiator if connected if hasattr(self.arpeggiator, 'pattern_completed'): try: self.arpeggiator.pattern_completed.disconnect(self.on_pattern_completed) except TypeError: pass # Already disconnected def get_current_group_preset(self): """Get the currently active preset in the group""" if 0 <= self.group_current_index < len(self.preset_group): return self.preset_group[self.group_current_index] return None def advance_group_preset(self): """Advance to the next preset in the group""" print(f"DEBUG: advance_group_preset called - enabled: {self.group_enabled}, group_size: {len(self.preset_group)}") if not self.group_enabled or len(self.preset_group) == 0: print("DEBUG: advance_group_preset - early return (not enabled or no presets)") return old_index = self.group_current_index # Move to next preset if self.group_order == "random": print("DEBUG: Using random order") # Pick a random preset that's different from current (if possible) if len(self.preset_group) > 1: available_indices = [i for i in range(len(self.preset_group)) if i != self.group_current_index] self.group_current_index = random.choice(available_indices) # If only one preset, stay on it else: # in_order print("DEBUG: Using in_order") self.group_current_index = (self.group_current_index + 1) % len(self.preset_group) print(f"DEBUG: Index changed from {old_index} to {self.group_current_index}") # Load the next preset next_preset = self.preset_group[self.group_current_index] print(f"DEBUG: Loading next preset: '{next_preset}'") if next_preset in self.presets: self.apply_preset_settings(self.presets[next_preset]) self.current_preset = next_preset print(f"Group cycling: Advanced to preset '{next_preset}' (index {self.group_current_index})") else: print(f"DEBUG: ERROR - preset '{next_preset}' not found in presets dict!") self.update_group_ui_state() self.update_group_preset_list() def goto_previous_preset(self): """Manually go to previous preset in group""" if not self.group_enabled or len(self.preset_group) <= 1: return self.group_current_index = (self.group_current_index - 1) % len(self.preset_group) self.group_current_loops = 0 prev_preset = self.preset_group[self.group_current_index] if prev_preset in self.presets: self.apply_preset_settings(self.presets[prev_preset]) self.current_preset = prev_preset self.update_group_ui_state() self.update_group_preset_list() def goto_next_preset(self): """Manually go to next preset in group""" if not self.group_enabled or len(self.preset_group) <= 1: return if self.group_order == "random": # Pick a random preset that's different from current (if possible) if len(self.preset_group) > 1: available_indices = [i for i in range(len(self.preset_group)) if i != self.group_current_index] self.group_current_index = random.choice(available_indices) else: # in_order self.group_current_index = (self.group_current_index + 1) % len(self.preset_group) self.group_current_loops = 0 next_preset = self.preset_group[self.group_current_index] if next_preset in self.presets: self.apply_preset_settings(self.presets[next_preset]) self.current_preset = next_preset self.update_group_ui_state() self.update_group_preset_list() # Note: Timer-based cycling replaced with note-counting approach def on_pattern_completed(self): """Handle arpeggiator pattern completion for timing""" # This method exists for future integration with arpeggiator pattern signals # For now, we use the playing state change to initiate cycling pass def on_playing_state_changed(self, is_playing): """Handle arpeggiator play/stop state changes""" print(f"DEBUG: on_playing_state_changed called - is_playing: {is_playing}, group_enabled: {self.group_enabled}, group_size: {len(self.preset_group)}") if is_playing and self.group_enabled and len(self.preset_group) > 0: print("DEBUG: Arpeggiator started - resetting note counter") # Reset note counter when arpeggiator starts playing self.group_pattern_note_count = 0 elif not is_playing: print("DEBUG: Arpeggiator stopped") # Stop any pending preset changes when arpeggiator stops self.group_timer.stop() def on_pattern_step(self, current_step): """Handle each pattern step (note) played by the arpeggiator""" if not self.group_enabled or len(self.preset_group) == 0: return # Increment our note counter self.group_pattern_note_count += 1 # Calculate how many notes should be played for current preset pattern_length = self.arpeggiator.user_pattern_length total_notes_needed = pattern_length * self.group_loop_count print(f"DEBUG: Pattern step {current_step}, note count: {self.group_pattern_note_count}/{total_notes_needed}") # Check if we've played enough notes to advance to next preset if self.group_pattern_note_count >= total_notes_needed: print("DEBUG: Note count reached, advancing to next preset") self.group_pattern_note_count = 0 # Reset counter self.advance_group_preset() # ======= MASTER FILE FUNCTIONALITY ======= def save_master_file(self): """Save current presets and group configuration as a master file""" try: # Create master files directory if it doesn't exist master_dir = "master_files" os.makedirs(master_dir, exist_ok=True) # Open file dialog filename, _ = QFileDialog.getSaveFileName( self, "Save Master File", os.path.join(master_dir, "master.json"), "Master Files (*.json);;All Files (*)" ) if not filename: return # Capture master file data master_data = { "version": "1.0", "timestamp": os.path.basename(filename).replace('.json', ''), "type": "master_file", # All individual presets "presets": self.presets.copy(), # Preset group configuration "preset_group": { "enabled": self.group_enabled, "presets": self.preset_group.copy(), "loop_count": self.group_loop_count, "order": self.group_order, "current_index": self.group_current_index, "current_loops": self.group_current_loops } } # Add timestamp from datetime import datetime master_data["timestamp"] = datetime.now().isoformat() # Write to file with open(filename, 'w') as f: json.dump(master_data, f, indent=2) QMessageBox.information(self, "Master File Saved", f"Master file saved successfully:\n{filename}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save master file:\n{str(e)}") def load_master_file(self): """Load presets and group configuration from a master file""" try: # Open file dialog master_dir = "master_files" os.makedirs(master_dir, exist_ok=True) filename, _ = QFileDialog.getOpenFileName( self, "Load Master File", master_dir, "Master Files (*.json);;All Files (*)" ) if not filename: return # Confirm loading (this will replace current presets) reply = QMessageBox.question( self, "Load Master File", "This will replace all current presets and group configuration.\n" "Are you sure you want to continue?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply != QMessageBox.Yes: return # Load master file with open(filename, 'r') as f: master_data = json.load(f) # Validate master file if master_data.get("type") != "master_file": QMessageBox.warning(self, "Invalid File", "This doesn't appear to be a valid master file.") return # Stop any active group cycling self.stop_group_cycling() # Load presets loaded_presets = master_data.get("presets", {}) self.presets = loaded_presets.copy() # Update preset list display self.update_preset_list() # Load group configuration group_config = master_data.get("preset_group", {}) self.preset_group = group_config.get("presets", []) self.group_loop_count = group_config.get("loop_count", 1) self.group_order = group_config.get("order", "in_order") self.group_current_index = 0 # Reset to start self.group_current_loops = 0 # Reset loops # Update UI controls self.loop_count_spinbox.setValue(self.group_loop_count) order_text = "Random" if self.group_order == "random" else "In Order" self.order_combo.setCurrentText(order_text) # Update group list display self.update_group_preset_list() # Don't auto-enable group cycling - let user decide self.group_enabled = False self.group_enable_checkbox.setChecked(False) # Update all UI states self.update_group_ui_state() self.update_preset_list_colors() loaded_count = len(loaded_presets) group_count = len(self.preset_group) QMessageBox.information( self, "Master File Loaded", f"Successfully loaded:\n" f"• {loaded_count} presets\n" f"• Preset group with {group_count} presets\n\n" f"From: {os.path.basename(filename)}" ) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load master file:\n{str(e)}") def update_preset_list(self): """Update the main preset list display""" self.preset_list.clear() for preset_name in sorted(self.presets.keys()): item = QListWidgetItem(preset_name) self.preset_list.addItem(item) # Update current preset display if self.current_preset and self.current_preset in self.presets: self.current_preset_label.setText(self.current_preset) else: self.current_preset_label.setText("None")