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

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