Browse Source

Implement armed preset system and fix timing issues

- Add armed preset switching that applies at pattern end (like note/scale changes)
- Fix single-note pattern armed change detection
- Add preset system safety mechanisms (timeout, force apply, clear armed buttons)
- Fix delay timing to be absolute rather than relative to note speed
- Comprehensive error handling and UI safety checks for preset controls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
melancholytron 2 months ago
parent
commit
34c6651132
  1. 107
      core/arpeggiator_engine.py
  2. 3
      gui/main_window.py
  3. 162
      gui/preset_controls.py

107
core/arpeggiator_engine.py

@ -93,6 +93,15 @@ class ArpeggiatorEngine(QObject):
self.armed_scale = None
self.armed_pattern_type = None
self.armed_channel_distribution = None
self.armed_preset_data = None
self.preset_apply_callback = None # Callback function for applying presets
# Armed state timeout (30 seconds safety timeout)
self.armed_timeout = QTimer()
self.armed_timeout.setSingleShot(True)
self.armed_timeout.timeout.connect(self.armed_timeout_expired)
self.armed_timeout_duration = 30000 # 30 seconds in milliseconds
self.last_armed_apply_time = 0 # Track when armed changes were last applied
# Pattern loop tracking
self.pattern_loops_completed = 0
@ -240,6 +249,57 @@ class ArpeggiatorEngine(QObject):
self.armed_channel_distribution = None
self.armed_state_changed.emit()
def arm_preset(self, preset_data: dict):
"""Arm a preset for switching at pattern end"""
if preset_data:
self.armed_preset_data = preset_data
# Start timeout timer
self.armed_timeout.start(self.armed_timeout_duration)
self.armed_state_changed.emit()
def clear_armed_preset(self):
"""Clear armed preset"""
self.armed_preset_data = None
self.armed_state_changed.emit()
def set_preset_apply_callback(self, callback):
"""Set callback function for applying presets"""
self.preset_apply_callback = callback
def has_armed_changes(self) -> bool:
"""Check if any changes are armed"""
return any([
self.armed_root_note is not None,
self.armed_scale is not None,
self.armed_pattern_type is not None,
self.armed_channel_distribution is not None,
self.armed_preset_data is not None
])
def get_armed_preset_data(self):
"""Get armed preset data"""
return self.armed_preset_data
def force_apply_armed_changes(self):
"""Force apply all armed changes immediately (emergency override)"""
self.apply_armed_changes()
self.last_armed_apply_time = time.time() # Update cooldown timer
def clear_all_armed_changes(self):
"""Clear all armed changes without applying them"""
self.armed_root_note = None
self.armed_scale = None
self.armed_pattern_type = None
self.armed_channel_distribution = None
self.armed_preset_data = None
self.armed_timeout.stop() # Stop timeout timer
self.armed_state_changed.emit()
def armed_timeout_expired(self):
"""Handle armed state timeout - clear all armed changes"""
print("Armed state timeout expired - clearing all armed changes")
self.clear_all_armed_changes()
def set_tempo(self, bpm: float):
"""Set tempo in BPM"""
if 40 <= bpm <= 200:
@ -284,20 +344,14 @@ class ArpeggiatorEngine(QObject):
self.step_duration = note_duration / beats_per_second
def calculate_delay_step_duration(self):
"""Calculate time between delay steps based on tempo and delay timing relative to note speed"""
"""Calculate time between delay steps based on tempo and delay timing"""
beats_per_second = self.tempo / 60.0
# Get current note speed duration
current_note_duration = self.NOTE_SPEEDS[self.note_speed]
# Get delay timing duration
# Get delay timing duration in beats
delay_timing_duration = self.NOTE_SPEEDS[self.delay_timing]
# Calculate delay as multiple of note speed
delay_multiplier = delay_timing_duration / current_note_duration
# Calculate actual delay step duration
self.delay_step_duration = (current_note_duration * delay_multiplier) / beats_per_second
# Calculate actual delay step duration in seconds
self.delay_step_duration = delay_timing_duration / beats_per_second
def schedule_delays(self, channel: int, note: int, original_volume: int):
"""Schedule delay/echo repeats for a note"""
@ -723,9 +777,25 @@ class ArpeggiatorEngine(QObject):
self.current_step += 1
# Check if pattern completed a full loop
if old_pattern_position != 0 and self.pattern_position == 0:
pattern_completed = False
if self.pattern_length == 1:
# For single-note patterns, consider every step a completion
pattern_completed = True
self.pattern_loops_completed += 1
self.apply_armed_changes()
elif old_pattern_position != 0 and self.pattern_position == 0:
# For multi-note patterns, completion is when we wrap back to 0
pattern_completed = True
self.pattern_loops_completed += 1
if pattern_completed:
# Apply armed changes with cooldown to prevent rapid firing
current_time = time.time()
min_cooldown_time = 1.0 # Minimum 1 second between armed changes
if (self.has_armed_changes() and
current_time - self.last_armed_apply_time >= min_cooldown_time):
self.apply_armed_changes()
self.last_armed_apply_time = current_time
# Calculate next step time with swing
base_time = self.next_step_time + self.step_duration
@ -768,8 +838,21 @@ class ArpeggiatorEngine(QObject):
self.armed_channel_distribution = None
changes_applied = True
# Apply armed preset
if self.armed_preset_data is not None and self.preset_apply_callback:
try:
self.preset_apply_callback(self.armed_preset_data)
self.armed_preset_data = None
changes_applied = True
except Exception as e:
print(f"Error applying armed preset: {e}")
# Clear the armed preset to prevent lockup
self.armed_preset_data = None
changes_applied = True
# If any changes were applied, regenerate pattern and emit signal
if changes_applied:
self.armed_timeout.stop() # Stop timeout timer since changes were applied
self.regenerate_pattern()
self.armed_state_changed.emit()

3
gui/main_window.py

@ -97,6 +97,9 @@ class MainWindow(QMainWindow):
self.preset_controls = PresetControls(self.arpeggiator, self.channel_manager, self.volume_engine)
tab_widget.addTab(self.preset_controls, "Presets")
# Set up preset callback for armed preset system
self.arpeggiator.set_preset_apply_callback(self.preset_controls.apply_preset_settings)
# Status display at bottom
status_frame = self.create_status_display()
main_layout.addWidget(status_frame)

162
gui/preset_controls.py

@ -31,6 +31,10 @@ class PresetControls(QWidget):
self.setup_ui()
self.load_presets_from_directory()
self.load_first_preset_on_startup()
# Connect to armed state changes
self.arpeggiator.armed_state_changed.connect(self.on_armed_state_changed)
def setup_ui(self):
"""Set up the user interface"""
@ -111,6 +115,17 @@ class PresetControls(QWidget):
self.duplicate_button.clicked.connect(self.duplicate_selected_preset)
layout.addWidget(self.duplicate_button, 2, 1)
# Safety controls
self.force_apply_button = QPushButton("Force Apply Armed")
self.force_apply_button.clicked.connect(self.force_apply_armed)
self.force_apply_button.setStyleSheet("color: #ffaa00; font-weight: bold;")
layout.addWidget(self.force_apply_button, 3, 0)
self.clear_armed_button = QPushButton("Clear Armed")
self.clear_armed_button.clicked.connect(self.clear_armed)
self.clear_armed_button.setStyleSheet("color: #aa6666;")
layout.addWidget(self.clear_armed_button, 3, 1)
return group
def create_file_operations(self) -> QGroupBox:
@ -147,7 +162,13 @@ class PresetControls(QWidget):
"gate": self.arpeggiator.gate,
"swing": self.arpeggiator.swing,
"velocity": self.arpeggiator.velocity,
"tempo": self.arpeggiator.tempo
"tempo": self.arpeggiator.tempo,
"pattern_length": getattr(self.arpeggiator, 'pattern_length', 8),
"channel_distribution": self.arpeggiator.channel_distribution,
"delay_enabled": self.arpeggiator.delay_enabled,
"delay_length": self.arpeggiator.delay_length,
"delay_timing": self.arpeggiator.delay_timing,
"delay_fade": self.arpeggiator.delay_fade
},
# Channel settings
@ -173,6 +194,12 @@ class PresetControls(QWidget):
def apply_preset_settings(self, preset: dict):
"""Apply preset settings to the system"""
try:
# Find the preset name for this data (for tracking current preset)
preset_name = None
for name, data in self.presets.items():
if data == preset:
preset_name = name
break
# Apply arpeggiator settings
arp_settings = preset.get("arpeggiator", {})
self.arpeggiator.set_root_note(arp_settings.get("root_note", 60))
@ -185,6 +212,20 @@ class PresetControls(QWidget):
self.arpeggiator.set_velocity(arp_settings.get("velocity", 80))
self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0))
# Apply pattern length if available
if "pattern_length" in arp_settings:
if hasattr(self.arpeggiator, 'set_pattern_length'):
self.arpeggiator.set_pattern_length(arp_settings["pattern_length"])
# Apply channel distribution
self.arpeggiator.set_channel_distribution(arp_settings.get("channel_distribution", "up"))
# Apply delay settings
self.arpeggiator.set_delay_enabled(arp_settings.get("delay_enabled", False))
self.arpeggiator.set_delay_length(arp_settings.get("delay_length", 3))
self.arpeggiator.set_delay_timing(arp_settings.get("delay_timing", "1/4"))
self.arpeggiator.set_delay_fade(arp_settings.get("delay_fade", 0.3))
# Apply channel settings
channel_settings = preset.get("channels", {})
self.channel_manager.set_active_synth_count(
@ -220,6 +261,13 @@ class PresetControls(QWidget):
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()
except Exception as e:
QMessageBox.warning(self, "Preset Error", f"Error applying preset: {str(e)}")
@ -227,16 +275,28 @@ class PresetControls(QWidget):
@pyqtSlot(QListWidgetItem)
def on_preset_selected(self, item):
"""Handle preset selection"""
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)
try:
if not item:
return
preset_name = item.text()
# Enable/disable buttons based on selection
has_selection = preset_name is not None
self.load_button.setEnabled(has_selection)
self.update_button.setEnabled(has_selection)
self.delete_button.setEnabled(has_selection)
self.rename_button.setEnabled(has_selection)
self.duplicate_button.setEnabled(has_selection)
self.export_button.setEnabled(has_selection)
except RuntimeError:
# Item was deleted, disable all buttons
self.load_button.setEnabled(False)
self.update_button.setEnabled(False)
self.delete_button.setEnabled(False)
self.rename_button.setEnabled(False)
self.duplicate_button.setEnabled(False)
self.export_button.setEnabled(False)
@pyqtSlot(QListWidgetItem)
def on_preset_double_clicked(self, item):
@ -245,23 +305,30 @@ class PresetControls(QWidget):
@pyqtSlot()
def load_selected_preset(self):
"""Load the selected preset"""
"""Arm the selected preset for loading at pattern end"""
current_item = self.preset_list.currentItem()
if not current_item:
return
preset_name = current_item.text()
if preset_name in self.presets:
self.apply_preset_settings(self.presets[preset_name])
self.current_preset = preset_name
self.current_preset_label.setText(preset_name)
# Visual feedback
current_item.setBackground(Qt.darkGreen)
for i in range(self.preset_list.count()):
item = self.preset_list.item(i)
if item != current_item:
item.setBackground(Qt.transparent)
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):
@ -470,6 +537,18 @@ class PresetControls(QWidget):
self.refresh_preset_list()
def load_first_preset_on_startup(self):
"""Automatically load the first preset on startup if available"""
if self.preset_list.count() > 0:
# Get the first item (presets are sorted alphabetically)
first_item = self.preset_list.item(0)
if first_item:
preset_name = first_item.text()
if preset_name in self.presets:
# Apply immediately on startup (not armed)
self.apply_preset_settings(self.presets[preset_name])
self.preset_list.setCurrentItem(first_item)
def save_preset_to_file(self, name: str, preset_data: dict):
"""Save a preset to file"""
file_path = os.path.join(self.presets_directory, f"{name}.json")
@ -488,6 +567,21 @@ class PresetControls(QWidget):
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
@ -507,4 +601,22 @@ class PresetControls(QWidget):
if self.preset_list.currentItem():
self.update_selected_preset()
else:
self.save_new_preset()
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()
Loading…
Cancel
Save