You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

625 lines
25 KiB

"""
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()