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.
1515 lines
67 KiB
1515 lines
67 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, QSpinBox, QComboBox, QCheckBox,
|
|
QSplitter)
|
|
from PyQt5.QtCore import Qt, pyqtSlot, QTimer
|
|
import json
|
|
import os
|
|
import random
|
|
import mido
|
|
import time
|
|
|
|
class PresetControls(QWidget):
|
|
"""Control panel for preset management"""
|
|
|
|
def __init__(self, arpeggiator, channel_manager, volume_engine, arpeggiator_controls=None, volume_controls=None):
|
|
super().__init__()
|
|
self.arpeggiator = arpeggiator
|
|
self.channel_manager = channel_manager
|
|
self.volume_engine = volume_engine
|
|
self.arpeggiator_controls = arpeggiator_controls
|
|
self.volume_controls = volume_controls
|
|
|
|
# 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)
|
|
|
|
self.export_master_button = QPushButton("Export Master")
|
|
self.export_master_button.clicked.connect(self.export_master_midi)
|
|
self.export_master_button.setStyleSheet("background: #5a2d5a; color: #ffffff; font-weight: bold; font-size: 12px; border: 1px solid #8a4a8a; padding: 5px 10px;")
|
|
master_layout.addWidget(self.export_master_button, 1, 2)
|
|
|
|
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 (check for overrides first)
|
|
arp_settings = preset.get("arpeggiator", {})
|
|
|
|
# Only apply settings that aren't overridden
|
|
# Check both root_note and octave overrides for root note changes
|
|
if not self._is_parameter_overridden('root_note') and not self._is_parameter_overridden('octave'):
|
|
self.arpeggiator.set_root_note(arp_settings.get("root_note", 60))
|
|
print(f"DEBUG: Applied root_note: {arp_settings.get('root_note', 60)}")
|
|
else:
|
|
override_type = "root_note" if self._is_parameter_overridden('root_note') else "octave"
|
|
print(f"DEBUG: Skipping root_note - {override_type} parameter is overridden")
|
|
if not self._is_parameter_overridden('scale'):
|
|
self.arpeggiator.set_scale(arp_settings.get("scale", "major"))
|
|
if not self._is_parameter_overridden('scale_note_start'):
|
|
self.arpeggiator.set_scale_note_start(arp_settings.get("scale_note_start", 0))
|
|
if not self._is_parameter_overridden('pattern_type'):
|
|
self.arpeggiator.set_pattern_type(arp_settings.get("pattern_type", "up"))
|
|
# Check for octave override when changing octave_range
|
|
if not self._is_parameter_overridden('octave'):
|
|
octave_range = arp_settings.get("octave_range", 1)
|
|
print(f"DEBUG: Applying octave_range: {octave_range}")
|
|
self.arpeggiator.set_octave_range(octave_range)
|
|
else:
|
|
print(f"DEBUG: Skipping octave_range - octave parameter is overridden")
|
|
if not self._is_parameter_overridden('note_speed'):
|
|
self.arpeggiator.set_note_speed(arp_settings.get("note_speed", "1/8"))
|
|
if not self._is_parameter_overridden('gate'):
|
|
self.arpeggiator.set_gate(arp_settings.get("gate", 1.0))
|
|
if not self._is_parameter_overridden('swing'):
|
|
self.arpeggiator.set_swing(arp_settings.get("swing", 0.0))
|
|
if not self._is_parameter_overridden('velocity'):
|
|
self.arpeggiator.set_velocity(arp_settings.get("velocity", 80))
|
|
if not self._is_parameter_overridden('tempo'):
|
|
self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0))
|
|
|
|
# Apply user pattern length (check both old and new names for compatibility)
|
|
if not self._is_parameter_overridden('pattern_length'):
|
|
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
|
|
if not self._is_parameter_overridden('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
|
|
if not self._is_parameter_overridden('channel_distribution'):
|
|
self.arpeggiator.set_channel_distribution(arp_settings.get("channel_distribution", "up"))
|
|
|
|
# Apply delay settings
|
|
if not self._is_parameter_overridden('delay_enabled'):
|
|
self.arpeggiator.set_delay_enabled(arp_settings.get("delay_enabled", False))
|
|
if not self._is_parameter_overridden('delay_length'):
|
|
self.arpeggiator.set_delay_length(arp_settings.get("delay_length", 3))
|
|
if not self._is_parameter_overridden('delay_timing'):
|
|
self.arpeggiator.set_delay_timing(arp_settings.get("delay_timing", "1/4"))
|
|
if not self._is_parameter_overridden('delay_fade'):
|
|
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 (check for overrides first)
|
|
volume_settings = preset.get("volume_patterns", {})
|
|
|
|
if not self._is_volume_parameter_overridden('volume_pattern'):
|
|
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
|
|
if not self._is_volume_parameter_overridden('volume_range'):
|
|
global_vol = volume_settings.get("global_volume_range", (0.2, 1.0))
|
|
self.volume_engine.set_global_ranges(
|
|
global_vol[0], global_vol[1],
|
|
self.volume_engine.global_velocity_range[0], self.volume_engine.global_velocity_range[1]
|
|
)
|
|
|
|
if not self._is_volume_parameter_overridden('velocity_range'):
|
|
global_vel = volume_settings.get("global_velocity_range", (40, 127))
|
|
self.volume_engine.set_global_ranges(
|
|
self.volume_engine.global_volume_range[0], self.volume_engine.global_volume_range[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 _is_parameter_overridden(self, param_name):
|
|
"""Check if a parameter is overridden (checkbox checked)"""
|
|
if self.arpeggiator_controls and hasattr(self.arpeggiator_controls, 'parameter_overrides'):
|
|
is_overridden = self.arpeggiator_controls.parameter_overrides.get(param_name, False)
|
|
if is_overridden:
|
|
print(f"DEBUG: Skipping {param_name} - parameter is overridden")
|
|
return is_overridden
|
|
return False
|
|
|
|
def _is_volume_parameter_overridden(self, param_name):
|
|
"""Check if a volume parameter is overridden (checkbox checked)"""
|
|
if self.volume_controls and hasattr(self.volume_controls, 'is_parameter_overridden'):
|
|
is_overridden = self.volume_controls.is_parameter_overridden(param_name)
|
|
if is_overridden:
|
|
print(f"DEBUG: Skipping volume {param_name} - parameter is overridden")
|
|
return is_overridden
|
|
return False
|
|
|
|
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)
|
|
|
|
# Sync GUI controls to match the loaded preset values
|
|
if self.arpeggiator_controls and hasattr(self.arpeggiator_controls, 'update_gui_from_engine'):
|
|
self.arpeggiator_controls.update_gui_from_engine()
|
|
|
|
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 export_master_midi(self):
|
|
"""Export the current master preset sequence as a single MIDI file"""
|
|
try:
|
|
# Check if we have a loaded master preset group
|
|
if not hasattr(self, 'preset_group') or not self.preset_group:
|
|
QMessageBox.warning(self, "No Master Preset",
|
|
"No master preset loaded. Please load a master file first.")
|
|
return
|
|
|
|
# Open file dialog for saving MIDI file
|
|
filename, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Export Master Preset as MIDI",
|
|
"master_export.mid",
|
|
"MIDI Files (*.mid);;All Files (*)"
|
|
)
|
|
|
|
if not filename:
|
|
return
|
|
|
|
print(f"DEBUG: Exporting {len(self.preset_group)} presets: {self.preset_group}")
|
|
|
|
# Create new MIDI file with simpler approach
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
|
|
# Set tempo (120 BPM default)
|
|
track.append(mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(120), time=0))
|
|
|
|
# Process each preset in the master sequence with simplified timing
|
|
absolute_time = 0
|
|
last_time = 0
|
|
preset_duration_ticks = 1920 # 1 bar at 480 ticks per beat in 4/4 time
|
|
|
|
for preset_name in self.preset_group:
|
|
print(f"DEBUG: Processing preset: {preset_name}")
|
|
if preset_name in self.presets:
|
|
preset = self.presets[preset_name]
|
|
print(f"DEBUG: Found preset data for {preset_name}")
|
|
self._add_preset_to_midi_track_simple(track, preset, absolute_time, last_time)
|
|
absolute_time += preset_duration_ticks
|
|
last_time = absolute_time
|
|
else:
|
|
print(f"DEBUG: Preset {preset_name} not found in self.presets")
|
|
|
|
# Save the MIDI file
|
|
mid.save(filename)
|
|
|
|
QMessageBox.information(self, "MIDI Export Complete",
|
|
f"Master preset exported as MIDI file:\n{filename}\n\n"
|
|
f"Contains {len(self.preset_group)} presets\n"
|
|
f"Duration: {len(self.preset_group)} bars")
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
error_details = traceback.format_exc()
|
|
QMessageBox.critical(self, "Export Error", f"Failed to export MIDI file:\n{str(e)}\n\nDetails:\n{error_details}")
|
|
|
|
def _add_preset_to_midi_track(self, track, preset, preset_start_time, duration_ticks):
|
|
"""Add a single preset's MIDI data to the track"""
|
|
try:
|
|
arp_settings = preset.get("arpeggiator", {})
|
|
|
|
# Get preset parameters
|
|
root_note = arp_settings.get("root_note", 60) # Middle C default
|
|
scale = arp_settings.get("scale", "major")
|
|
scale_note_start = arp_settings.get("scale_note_start", 0)
|
|
pattern_type = arp_settings.get("pattern_type", "up")
|
|
note_speed = arp_settings.get("note_speed", "1/4")
|
|
gate = arp_settings.get("gate", 0.8)
|
|
velocity = arp_settings.get("velocity", 100)
|
|
user_pattern_length = arp_settings.get("user_pattern_length", 8)
|
|
|
|
# Scale definitions
|
|
scales = {
|
|
"major": [0, 2, 4, 5, 7, 9, 11],
|
|
"minor": [0, 2, 3, 5, 7, 8, 10],
|
|
"dorian": [0, 2, 3, 5, 7, 9, 10],
|
|
"phrygian": [0, 1, 3, 5, 7, 8, 10],
|
|
"lydian": [0, 2, 4, 6, 7, 9, 11],
|
|
"mixolydian": [0, 2, 4, 5, 7, 9, 10],
|
|
"locrian": [0, 1, 3, 5, 6, 8, 10],
|
|
"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
|
|
"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
|
|
"pentatonic": [0, 2, 4, 7, 9],
|
|
"blues": [0, 3, 5, 6, 7, 10]
|
|
}
|
|
|
|
scale_intervals = scales.get(scale, scales["major"])
|
|
|
|
# Calculate note timing based on note_speed (ticks per note)
|
|
note_speed_ticks = {
|
|
"1/1": 1920, # Whole note
|
|
"1/2": 960, # Half note
|
|
"1/4": 480, # Quarter note
|
|
"1/8": 240, # Eighth note
|
|
"1/16": 120, # Sixteenth note
|
|
"1/2T": 640, # Half note triplet
|
|
"1/4T": 320, # Quarter note triplet
|
|
"1/8T": 160 # Eighth note triplet
|
|
}
|
|
|
|
note_duration = note_speed_ticks.get(note_speed, 480)
|
|
gate_duration = int(note_duration * gate)
|
|
rest_duration = note_duration - gate_duration
|
|
|
|
# Generate scale notes
|
|
notes = []
|
|
# Handle "random" scale_note_start by converting to integer
|
|
if scale_note_start == "random":
|
|
import random
|
|
start_degree = random.randint(0, len(scale_intervals) - 1)
|
|
else:
|
|
start_degree = int(scale_note_start) % len(scale_intervals)
|
|
|
|
# Create simple ascending pattern for now
|
|
for i in range(user_pattern_length):
|
|
degree = (start_degree + i) % len(scale_intervals)
|
|
note = root_note + scale_intervals[degree]
|
|
if 0 <= note <= 127:
|
|
notes.append(note)
|
|
|
|
if not notes:
|
|
return
|
|
|
|
# Calculate how many notes fit in the duration
|
|
notes_in_duration = duration_ticks // note_duration
|
|
|
|
# Add gap at start of preset (except for first preset)
|
|
current_time = preset_start_time
|
|
|
|
# Add MIDI notes for this preset
|
|
for i in range(int(notes_in_duration)):
|
|
note = notes[i % len(notes)]
|
|
|
|
# Note on
|
|
track.append(mido.Message('note_on',
|
|
channel=0,
|
|
note=note,
|
|
velocity=int(velocity),
|
|
time=current_time))
|
|
|
|
# Note off
|
|
track.append(mido.Message('note_off',
|
|
channel=0,
|
|
note=note,
|
|
velocity=0,
|
|
time=gate_duration))
|
|
|
|
# Time until next note (only rest_duration since we already used gate_duration)
|
|
current_time = rest_duration
|
|
|
|
except Exception as e:
|
|
print(f"Error adding preset to MIDI track: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
def _add_preset_to_midi_track_simple(self, track, preset, absolute_start_time, last_time):
|
|
"""Add a single preset's MIDI data to the track with simplified timing"""
|
|
try:
|
|
arp_settings = preset.get("arpeggiator", {})
|
|
|
|
# Get basic preset parameters
|
|
root_note = arp_settings.get("root_note", 60)
|
|
velocity = arp_settings.get("velocity", 100)
|
|
|
|
# Simple scale - just use major scale starting from root
|
|
notes = [root_note, root_note + 2, root_note + 4, root_note + 5,
|
|
root_note + 7, root_note + 9, root_note + 11, root_note + 12]
|
|
|
|
# Filter to valid MIDI range
|
|
notes = [n for n in notes if 0 <= n <= 127]
|
|
|
|
if not notes:
|
|
return
|
|
|
|
# Add a few notes for this preset (quarter notes)
|
|
note_duration = 480 # Quarter note in ticks
|
|
gate_duration = 400 # Slightly shorter than full duration
|
|
|
|
# Time since last event
|
|
delta_time = absolute_start_time - last_time
|
|
|
|
for i in range(4): # 4 quarter notes per preset
|
|
note = notes[i % len(notes)]
|
|
|
|
# Note on
|
|
track.append(mido.Message('note_on',
|
|
channel=0,
|
|
note=note,
|
|
velocity=int(velocity),
|
|
time=delta_time if i == 0 else (note_duration - gate_duration)))
|
|
|
|
# Note off
|
|
track.append(mido.Message('note_off',
|
|
channel=0,
|
|
note=note,
|
|
velocity=0,
|
|
time=gate_duration))
|
|
|
|
delta_time = 0 # Only first note has the preset gap
|
|
|
|
except Exception as e:
|
|
print(f"Error in simple MIDI track: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
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")
|