""" Arpeggiator Controls GUI - Complete Redesign Clean quadrant layout with no overlapping buttons, guaranteed spacing. """ from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, QPushButton, QCheckBox, QFrame, QScrollArea, QSizePolicy) from PyQt5.QtCore import Qt, pyqtSlot class ArpeggiatorControls(QWidget): """Control panel for arpeggiator parameters - redesigned for no overlaps""" def __init__(self, arpeggiator, channel_manager): super().__init__() self.arpeggiator = arpeggiator self.channel_manager = channel_manager # Preset system self.presets = {} self.current_preset = None self.preset_rotation_enabled = False self.preset_rotation_interval = 4 self.preset_rotation_timer = None self.pattern_count_since_preset_change = 0 # Button tracking self.root_note_buttons = {} self.octave_buttons = {} self.scale_buttons = {} self.pattern_buttons = {} self.distribution_buttons = {} # Current states self.current_root_note = 0 # C self.current_octave = 4 # C4 self.current_scale = "major" self.current_pattern = "up" self.current_distribution = "up" # Armed states self.armed_root_note_button = None self.armed_octave_button = None self.armed_scale_button = None self.armed_pattern_button = None self.armed_distribution_button = None self.setup_ui() self.connect_signals() def setup_ui(self): """Set up clean quadrant layout""" # Main layout - fixed size quadrants main_layout = QGridLayout(self) main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) # Create four equal quadrants basic_quad = self.create_basic_quadrant() distribution_quad = self.create_distribution_quadrant() pattern_quad = self.create_pattern_quadrant() timing_quad = self.create_timing_quadrant() # Add to grid with equal sizing main_layout.addWidget(basic_quad, 0, 0) main_layout.addWidget(distribution_quad, 0, 1) main_layout.addWidget(pattern_quad, 1, 0) main_layout.addWidget(timing_quad, 1, 1) # Make all quadrants equal main_layout.setRowStretch(0, 1) main_layout.setRowStretch(1, 1) main_layout.setColumnStretch(0, 1) main_layout.setColumnStretch(1, 1) def create_basic_quadrant(self): """Create basic settings quadrant - guaranteed no overlaps""" group = QGroupBox("Basic Settings") layout = QVBoxLayout(group) layout.setSpacing(8) layout.setContentsMargins(8, 8, 8, 8) # Root Note - 12 buttons in 3 rows of 4 root_label = QLabel("Root Note:") layout.addWidget(root_label) root_container = QWidget() root_layout = QGridLayout(root_container) root_layout.setSpacing(4) root_layout.setContentsMargins(0, 0, 0, 0) notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] for i, note in enumerate(notes): btn = QPushButton(note) btn.setFixedSize(35, 25) btn.setCheckable(True) btn.clicked.connect(lambda checked, n=i: self.on_root_note_clicked(n)) if i == 0: # C is default btn.setChecked(True) self.set_button_style(btn, "active") else: self.set_button_style(btn, "inactive") self.root_note_buttons[i] = btn root_layout.addWidget(btn, i // 4, i % 4) layout.addWidget(root_container) # Octave - 6 buttons in 2 rows of 3 octave_label = QLabel("Octave:") layout.addWidget(octave_label) octave_container = QWidget() octave_layout = QGridLayout(octave_container) octave_layout.setSpacing(4) octave_layout.setContentsMargins(0, 0, 0, 0) for i in range(6): # C3 to C8 octave = i + 3 btn = QPushButton(f"C{octave}") btn.setFixedSize(35, 25) btn.setCheckable(True) btn.clicked.connect(lambda checked, o=octave: self.on_octave_clicked(o)) if octave == 4: # C4 is default btn.setChecked(True) self.set_button_style(btn, "active") else: self.set_button_style(btn, "inactive") self.octave_buttons[octave] = btn octave_layout.addWidget(btn, i // 3, i % 3) layout.addWidget(octave_container) # Scales - 8 main scales in 4 rows of 2 scale_label = QLabel("Scale:") layout.addWidget(scale_label) scale_container = QWidget() scale_layout = QGridLayout(scale_container) scale_layout.setSpacing(4) scale_layout.setContentsMargins(0, 0, 0, 0) main_scales = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "pentatonic_major", "pentatonic_minor"] for i, scale in enumerate(main_scales): display_name = scale.replace("_", " ").title() if len(display_name) > 8: display_name = display_name[:8] btn = QPushButton(display_name) btn.setFixedSize(75, 25) btn.setCheckable(True) btn.clicked.connect(lambda checked, s=scale: self.on_scale_clicked(s)) if scale == "major": # Major is default btn.setChecked(True) self.set_button_style(btn, "active") else: self.set_button_style(btn, "inactive") self.scale_buttons[scale] = btn scale_layout.addWidget(btn, i // 2, i % 2) layout.addWidget(scale_container) # Octave Range dropdown range_label = QLabel("Octave Range:") layout.addWidget(range_label) self.octave_range_combo = QComboBox() for i in range(1, 5): self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") self.octave_range_combo.setCurrentIndex(0) layout.addWidget(self.octave_range_combo) return group def create_distribution_quadrant(self): """Create channel distribution quadrant""" group = QGroupBox("Channel Distribution") layout = QVBoxLayout(group) layout.setSpacing(8) layout.setContentsMargins(8, 8, 8, 8) # Distribution patterns - 8 patterns in 4 rows of 2 dist_label = QLabel("Distribution Pattern:") layout.addWidget(dist_label) dist_container = QWidget() dist_layout = QGridLayout(dist_container) dist_layout.setSpacing(4) dist_layout.setContentsMargins(0, 0, 0, 0) patterns = ["up", "down", "up_down", "bounce", "random", "cycle", "alternating", "single_channel"] for i, pattern in enumerate(patterns): display_name = pattern.replace("_", " ").title() if len(display_name) > 10: display_name = display_name[:10] btn = QPushButton(display_name) btn.setFixedSize(85, 25) btn.setCheckable(True) btn.clicked.connect(lambda checked, p=pattern: self.on_distribution_clicked(p)) if pattern == "up": # Up is default btn.setChecked(True) self.set_button_style(btn, "active") else: self.set_button_style(btn, "inactive") self.distribution_buttons[pattern] = btn dist_layout.addWidget(btn, i // 2, i % 2) layout.addWidget(dist_container) # Description self.distribution_description = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") self.distribution_description.setStyleSheet("color: #666; font-style: italic; font-size: 10px;") self.distribution_description.setWordWrap(True) layout.addWidget(self.distribution_description) return group def create_pattern_quadrant(self): """Create pattern settings quadrant""" group = QGroupBox("Pattern Settings") layout = QVBoxLayout(group) layout.setSpacing(8) layout.setContentsMargins(8, 8, 8, 8) # Arpeggio patterns - 8 patterns in 4 rows of 2 pattern_label = QLabel("Arpeggio Pattern:") layout.addWidget(pattern_label) pattern_container = QWidget() pattern_layout = QGridLayout(pattern_container) pattern_layout.setSpacing(4) pattern_layout.setContentsMargins(0, 0, 0, 0) patterns = self.arpeggiator.PATTERN_TYPES[:8] # First 8 patterns for i, pattern in enumerate(patterns): display_name = pattern.replace("_", " ").title() if len(display_name) > 10: display_name = display_name[:10] btn = QPushButton(display_name) btn.setFixedSize(85, 25) btn.setCheckable(True) btn.clicked.connect(lambda checked, p=pattern: self.on_pattern_clicked(p)) if pattern == "up": # Up is default btn.setChecked(True) self.set_button_style(btn, "active") else: self.set_button_style(btn, "inactive") self.pattern_buttons[pattern] = btn pattern_layout.addWidget(btn, i // 2, i % 2) layout.addWidget(pattern_container) return group def create_timing_quadrant(self): """Create timing settings quadrant""" group = QGroupBox("Timing Settings") layout = QVBoxLayout(group) layout.setSpacing(8) layout.setContentsMargins(8, 8, 8, 8) # Tempo tempo_layout = QHBoxLayout() tempo_layout.addWidget(QLabel("Tempo:")) self.tempo_spin = QSpinBox() self.tempo_spin.setRange(40, 200) self.tempo_spin.setValue(120) self.tempo_spin.setSuffix(" BPM") tempo_layout.addWidget(self.tempo_spin) layout.addLayout(tempo_layout) # Note Speed speed_layout = QHBoxLayout() speed_layout.addWidget(QLabel("Speed:")) self.speed_combo = QComboBox() speeds = list(self.arpeggiator.NOTE_SPEEDS.keys()) for speed in speeds: self.speed_combo.addItem(speed) self.speed_combo.setCurrentText("1/8") speed_layout.addWidget(self.speed_combo) layout.addLayout(speed_layout) # Gate gate_layout = QHBoxLayout() gate_layout.addWidget(QLabel("Gate:")) self.gate_slider = QSlider(Qt.Horizontal) self.gate_slider.setRange(10, 200) self.gate_slider.setValue(100) gate_layout.addWidget(self.gate_slider) self.gate_label = QLabel("100%") self.gate_label.setFixedWidth(35) gate_layout.addWidget(self.gate_label) layout.addLayout(gate_layout) # Swing swing_layout = QHBoxLayout() swing_layout.addWidget(QLabel("Swing:")) self.swing_slider = QSlider(Qt.Horizontal) self.swing_slider.setRange(-100, 100) self.swing_slider.setValue(0) swing_layout.addWidget(self.swing_slider) self.swing_label = QLabel("0%") self.swing_label.setFixedWidth(35) swing_layout.addWidget(self.swing_label) layout.addLayout(swing_layout) # Velocity velocity_layout = QHBoxLayout() velocity_layout.addWidget(QLabel("Velocity:")) self.velocity_slider = QSlider(Qt.Horizontal) self.velocity_slider.setRange(1, 127) self.velocity_slider.setValue(80) velocity_layout.addWidget(self.velocity_slider) self.velocity_label = QLabel("80") self.velocity_label.setFixedWidth(35) velocity_layout.addWidget(self.velocity_label) layout.addLayout(velocity_layout) # Preset controls preset_layout = QHBoxLayout() self.save_preset_btn = QPushButton("Save") self.save_preset_btn.setFixedSize(50, 25) self.load_preset_btn = QPushButton("Load") self.load_preset_btn.setFixedSize(50, 25) preset_layout.addWidget(self.save_preset_btn) preset_layout.addWidget(self.load_preset_btn) preset_layout.addStretch() layout.addLayout(preset_layout) return group def set_button_style(self, button, state): """Set button style based on state""" if state == "active": button.setStyleSheet(""" QPushButton { background-color: #2d5a2d; color: white; border: 2px solid #4a8a4a; font-weight: bold; font-size: 10px; } """) elif state == "armed": button.setStyleSheet(""" QPushButton { background-color: #5a4d2d; color: white; border: 2px solid #8a7a4a; font-weight: bold; font-size: 10px; } """) else: # inactive button.setStyleSheet(""" QPushButton { font-size: 10px; } """) def connect_signals(self): """Connect all signals""" # Timing controls self.tempo_spin.valueChanged.connect(self.on_tempo_changed) self.speed_combo.currentTextChanged.connect(self.on_speed_changed) self.gate_slider.valueChanged.connect(self.on_gate_changed) self.swing_slider.valueChanged.connect(self.on_swing_changed) self.velocity_slider.valueChanged.connect(self.on_velocity_changed) self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) # Preset controls self.save_preset_btn.clicked.connect(self.save_current_preset) self.load_preset_btn.clicked.connect(self.load_preset_dialog) # Arpeggiator signals self.arpeggiator.armed_state_changed.connect(self.update_armed_states) # Event handlers def on_root_note_clicked(self, note_index): """Handle root note button click""" midi_note = self.current_octave * 12 + note_index if self.arpeggiator.is_playing: self.arm_root_note(note_index) self.arpeggiator.arm_root_note(midi_note) else: self.set_active_root_note(note_index) self.arpeggiator.set_root_note(midi_note) def on_octave_clicked(self, octave): """Handle octave button click""" midi_note = octave * 12 + self.current_root_note if self.arpeggiator.is_playing: self.arm_octave(octave) self.arpeggiator.arm_root_note(midi_note) else: self.set_active_octave(octave) self.arpeggiator.set_root_note(midi_note) def on_scale_clicked(self, scale): """Handle scale button click""" if self.arpeggiator.is_playing: self.arm_scale(scale) self.arpeggiator.arm_scale(scale) else: self.set_active_scale(scale) self.arpeggiator.set_scale(scale) def on_pattern_clicked(self, pattern): """Handle pattern button click""" if self.arpeggiator.is_playing: self.arm_pattern(pattern) self.arpeggiator.arm_pattern_type(pattern) else: self.set_active_pattern(pattern) self.arpeggiator.set_pattern_type(pattern) def on_distribution_clicked(self, distribution): """Handle distribution button click""" if self.arpeggiator.is_playing: self.arm_distribution(distribution) self.arpeggiator.arm_channel_distribution(distribution) else: self.set_active_distribution(distribution) self.arpeggiator.set_channel_distribution(distribution) # State management def set_active_root_note(self, note_index): """Set active root note""" if self.current_root_note in self.root_note_buttons: self.set_button_style(self.root_note_buttons[self.current_root_note], "inactive") self.current_root_note = note_index if note_index in self.root_note_buttons: self.set_button_style(self.root_note_buttons[note_index], "active") def set_active_octave(self, octave): """Set active octave""" if self.current_octave in self.octave_buttons: self.set_button_style(self.octave_buttons[self.current_octave], "inactive") self.current_octave = octave if octave in self.octave_buttons: self.set_button_style(self.octave_buttons[octave], "active") def set_active_scale(self, scale): """Set active scale""" if self.current_scale in self.scale_buttons: self.set_button_style(self.scale_buttons[self.current_scale], "inactive") self.current_scale = scale if scale in self.scale_buttons: self.set_button_style(self.scale_buttons[scale], "active") def set_active_pattern(self, pattern): """Set active pattern""" if self.current_pattern in self.pattern_buttons: self.set_button_style(self.pattern_buttons[self.current_pattern], "inactive") self.current_pattern = pattern if pattern in self.pattern_buttons: self.set_button_style(self.pattern_buttons[pattern], "active") def set_active_distribution(self, distribution): """Set active distribution""" if self.current_distribution in self.distribution_buttons: self.set_button_style(self.distribution_buttons[self.current_distribution], "inactive") self.current_distribution = distribution if distribution in self.distribution_buttons: self.set_button_style(self.distribution_buttons[distribution], "active") self.update_distribution_description(distribution) def arm_root_note(self, note_index): """Arm root note for pattern-end change""" if self.armed_root_note_button: self.set_button_style(self.armed_root_note_button, "inactive") self.armed_root_note_button = self.root_note_buttons[note_index] self.set_button_style(self.armed_root_note_button, "armed") def arm_octave(self, octave): """Arm octave for pattern-end change""" if self.armed_octave_button: self.set_button_style(self.armed_octave_button, "inactive") self.armed_octave_button = self.octave_buttons[octave] self.set_button_style(self.armed_octave_button, "armed") def arm_scale(self, scale): """Arm scale for pattern-end change""" if self.armed_scale_button: self.set_button_style(self.armed_scale_button, "inactive") self.armed_scale_button = self.scale_buttons[scale] self.set_button_style(self.armed_scale_button, "armed") def arm_pattern(self, pattern): """Arm pattern for pattern-end change""" if self.armed_pattern_button: self.set_button_style(self.armed_pattern_button, "inactive") self.armed_pattern_button = self.pattern_buttons[pattern] self.set_button_style(self.armed_pattern_button, "armed") def arm_distribution(self, distribution): """Arm distribution for pattern-end change""" if self.armed_distribution_button: self.set_button_style(self.armed_distribution_button, "inactive") self.armed_distribution_button = self.distribution_buttons[distribution] self.set_button_style(self.armed_distribution_button, "armed") @pyqtSlot() def update_armed_states(self): """Update when armed states are applied""" if self.armed_root_note_button and self.arpeggiator.armed_root_note is None: note_index = None for n, btn in self.root_note_buttons.items(): if btn == self.armed_root_note_button: note_index = n break if note_index is not None: self.set_active_root_note(note_index) if self.armed_octave_button and self.arpeggiator.armed_root_note is None: octave = None for o, btn in self.octave_buttons.items(): if btn == self.armed_octave_button: octave = o break if octave is not None: self.set_active_octave(octave) if self.armed_scale_button and self.arpeggiator.armed_scale is None: scale = None for s, btn in self.scale_buttons.items(): if btn == self.armed_scale_button: scale = s break if scale: self.set_active_scale(scale) if self.armed_pattern_button and self.arpeggiator.armed_pattern_type is None: pattern = None for p, btn in self.pattern_buttons.items(): if btn == self.armed_pattern_button: pattern = p break if pattern: self.set_active_pattern(pattern) if self.armed_distribution_button and self.arpeggiator.armed_channel_distribution is None: distribution = None for d, btn in self.distribution_buttons.items(): if btn == self.armed_distribution_button: distribution = d break if distribution: self.set_active_distribution(distribution) def update_distribution_description(self, distribution: str): """Update distribution description""" descriptions = { "up": "Channels: 1 → 2 → 3 → 4 → 5 → 6...", "down": "Channels: 6 → 5 → 4 → 3 → 2 → 1...", "up_down": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", "bounce": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", "random": "Channels: Random selection each note", "cycle": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 1 → 2...", "alternating": "Channels: 1 → 6 → 2 → 5 → 3 → 4...", "single_channel": "Channels: All notes on channel 1" } self.distribution_description.setText(descriptions.get(distribution, "Unknown pattern")) # Timing control handlers @pyqtSlot(int) def on_tempo_changed(self, tempo): self.arpeggiator.set_tempo(float(tempo)) @pyqtSlot(str) def on_speed_changed(self, speed): self.arpeggiator.set_note_speed(speed) @pyqtSlot(int) def on_gate_changed(self, gate_percent): self.arpeggiator.set_gate(gate_percent / 100.0) self.gate_label.setText(f"{gate_percent}%") @pyqtSlot(int) def on_swing_changed(self, swing_percent): self.arpeggiator.set_swing(swing_percent / 100.0) self.swing_label.setText(f"{swing_percent}%") @pyqtSlot(int) def on_velocity_changed(self, velocity): self.arpeggiator.set_velocity(velocity) self.velocity_label.setText(str(velocity)) @pyqtSlot(int) def on_octave_range_changed(self, index): octaves = index + 1 self.arpeggiator.set_octave_range(octaves) # Preset system def save_current_preset(self): """Save current settings as preset""" preset_name = f"Preset_{len(self.presets) + 1}" preset = { 'root_note': self.current_root_note, 'octave': self.current_octave, 'scale': self.current_scale, 'pattern': self.current_pattern, 'distribution': self.current_distribution, 'octave_range': self.octave_range_combo.currentIndex(), 'tempo': self.tempo_spin.value(), 'speed': self.speed_combo.currentText(), 'gate': self.gate_slider.value(), 'swing': self.swing_slider.value(), 'velocity': self.velocity_slider.value() } self.presets[preset_name] = preset print(f"Saved {preset_name}") def load_preset_dialog(self): """Load next preset""" if not self.presets: print("No presets saved") return preset_names = list(self.presets.keys()) if self.current_preset in preset_names: current_index = preset_names.index(self.current_preset) next_index = (current_index + 1) % len(preset_names) else: next_index = 0 self.load_preset(preset_names[next_index]) def load_preset(self, preset_name: str): """Load specific preset""" if preset_name not in self.presets: return preset = self.presets[preset_name] self.current_preset = preset_name # Apply settings if self.arpeggiator.is_playing: # Arm changes midi_note = preset['octave'] * 12 + preset['root_note'] self.arpeggiator.arm_root_note(midi_note) self.arpeggiator.arm_scale(preset['scale']) self.arpeggiator.arm_pattern_type(preset['pattern']) self.arpeggiator.arm_channel_distribution(preset['distribution']) else: # Apply immediately self.set_active_root_note(preset['root_note']) self.set_active_octave(preset['octave']) self.set_active_scale(preset['scale']) self.set_active_pattern(preset['pattern']) self.set_active_distribution(preset['distribution']) midi_note = preset['octave'] * 12 + preset['root_note'] self.arpeggiator.set_root_note(midi_note) self.arpeggiator.set_scale(preset['scale']) self.arpeggiator.set_pattern_type(preset['pattern']) self.arpeggiator.set_channel_distribution(preset['distribution']) # Apply other settings self.octave_range_combo.setCurrentIndex(preset['octave_range']) self.tempo_spin.setValue(preset['tempo']) self.speed_combo.setCurrentText(preset['speed']) self.gate_slider.setValue(preset['gate']) self.swing_slider.setValue(preset['swing']) self.velocity_slider.setValue(preset['velocity']) print(f"Loaded {preset_name}")