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.
 

691 lines
27 KiB

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