""" 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) from PyQt5.QtCore import Qt, pyqtSlot import json import os 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" # 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) def setup_ui(self): """Set up the user interface""" layout = QVBoxLayout(self) # Preset list preset_group = self.create_preset_list() layout.addWidget(preset_group) # Preset operations operations_group = self.create_operations() layout.addWidget(operations_group) # File operations file_group = self.create_file_operations() layout.addWidget(file_group) 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) 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) 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) 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("color: #aa6666;") 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) 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) 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: """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) 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) layout.addWidget(self.export_button) 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, "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, "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 "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_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 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( 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) 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()