Browse Source

Implement scale note selection and fix directional arpeggios

Major Features:
- Add scale note selection with armed state functionality
- Fix down arpeggio patterns to be contextual and smooth
- Make octave range direction-aware (up goes higher, down goes lower)

Scale Note Selection:
- Added scale notes display showing current scale notes as buttons
- Implemented armed state for scale note changes during playback
- Scale notes update dynamically when root note or scale changes
- Added scale_note_start property to engine for starting position

Fixed Arpeggio Patterns:
- Rewrote scale generation to be direction-aware
- Down patterns now continue downward instead of looping back up
- Up patterns go higher from starting note, down patterns go lower
- Fixed octave wrapping for all starting scale degrees

Volume Pattern Improvements:
- Added multi-bar swell patterns (1-16 bar swells)
- Added accent patterns (every 2nd-8th note)
- Replaced random_sparkle with simple random pattern
- Synchronized volume patterns with arpeggiator steps

GUI Enhancements:
- Increased control heights for better usability
- Added scale notes section with dynamic button generation
- Made dropdown menus taller for readability
- Fixed GUI updates when armed changes are applied

Technical Improvements:
- Added settings_changed signal for GUI synchronization
- Improved armed state system with comprehensive change detection
- Fixed preset system to save/load all settings including delays
- Enhanced pattern completion detection for single-note patterns

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

Co-Authored-By: Claude <noreply@anthropic.com>
master
melancholytron 2 months ago
parent
commit
f316129731
  1. 192
      core/arpeggiator_engine.py
  2. 88
      core/volume_pattern_engine.py
  3. 237
      gui/arpeggiator_controls.py
  4. 2
      gui/channel_controls.py
  5. 1
      gui/output_controls.py
  6. 3
      gui/preset_controls.py
  7. 44
      gui/volume_controls.py
  8. 57
      presets/butt 2.json

192
core/arpeggiator_engine.py

@ -28,6 +28,7 @@ class ArpeggiatorEngine(QObject):
tempo_changed = pyqtSignal(float) # BPM
playing_state_changed = pyqtSignal(bool) # is_playing
armed_state_changed = pyqtSignal() # armed state changed
settings_changed = pyqtSignal() # Any setting changed (for GUI updates)
# Arpeggio pattern types (FL Studio style)
PATTERN_TYPES = [
@ -83,6 +84,7 @@ class ArpeggiatorEngine(QObject):
self.gate = 1.0 # Note length as fraction of step (0.1-2.0)
self.swing = 0.0 # Swing amount (-100% to +100%)
self.velocity = 80 # Base velocity
self.scale_note_start = 0 # Starting scale note index (0 = root)
# Channel distribution settings
self.channel_distribution = "up" # How notes are distributed across channels
@ -93,6 +95,7 @@ class ArpeggiatorEngine(QObject):
self.armed_scale = None
self.armed_pattern_type = None
self.armed_channel_distribution = None
self.armed_scale_note_start = None
self.armed_preset_data = None
self.preset_apply_callback = None # Callback function for applying presets
@ -216,9 +219,8 @@ class ArpeggiatorEngine(QObject):
if speed in self.NOTE_SPEEDS:
self.note_speed = speed
self.calculate_step_duration()
# Also update delay timing if it matches note speed
if self.delay_timing == speed:
self.calculate_delay_step_duration()
# Always recalculate delay timing since it's relative to note speed
self.calculate_delay_step_duration()
def set_gate(self, gate: float):
"""Set gate (note length) 0.1-2.0"""
@ -232,6 +234,21 @@ class ArpeggiatorEngine(QObject):
"""Set base velocity 0-127"""
self.velocity = max(0, min(127, velocity))
def set_scale_note_start(self, scale_note_index: int):
"""Set which scale note to start the arpeggio from"""
self.scale_note_start = max(0, scale_note_index)
self.regenerate_pattern()
def arm_scale_note_start(self, scale_note_index: int):
"""Arm a scale note start position to change at pattern end"""
self.armed_scale_note_start = max(0, scale_note_index)
self.armed_state_changed.emit()
def clear_armed_scale_note_start(self):
"""Clear armed scale note start position"""
self.armed_scale_note_start = None
self.armed_state_changed.emit()
def set_channel_distribution(self, distribution: str):
"""Set channel distribution pattern"""
if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS:
@ -273,6 +290,7 @@ class ArpeggiatorEngine(QObject):
self.armed_scale is not None,
self.armed_pattern_type is not None,
self.armed_channel_distribution is not None,
self.armed_scale_note_start is not None,
self.armed_preset_data is not None
])
@ -291,6 +309,7 @@ class ArpeggiatorEngine(QObject):
self.armed_scale = None
self.armed_pattern_type = None
self.armed_channel_distribution = None
self.armed_scale_note_start = None
self.armed_preset_data = None
self.armed_timeout.stop() # Stop timeout timer
self.armed_state_changed.emit()
@ -344,14 +363,22 @@ 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"""
"""Calculate time between delay steps as a fraction of the current note speed"""
beats_per_second = self.tempo / 60.0
# Get delay timing duration in beats
delay_timing_duration = self.NOTE_SPEEDS[self.delay_timing]
# Get current note speed duration in beats
note_speed_duration = self.NOTE_SPEEDS[self.note_speed]
# Get delay timing as a fraction
delay_timing_fraction = self.NOTE_SPEEDS[self.delay_timing]
# Delay interval = note_speed × delay_timing_fraction
# For example: note_speed=1/1, delay_timing=1/4 → delay every 1/4 beat
# Or: note_speed=1/2, delay_timing=1/4 → delay every 1/8 beat (1/2 × 1/4)
delay_interval_beats = note_speed_duration * delay_timing_fraction
# Calculate actual delay step duration in seconds
self.delay_step_duration = delay_timing_duration / beats_per_second
# Convert to seconds
self.delay_step_duration = delay_interval_beats / beats_per_second
def schedule_delays(self, channel: int, note: int, original_volume: int):
"""Schedule delay/echo repeats for a note"""
@ -441,6 +468,8 @@ class ArpeggiatorEngine(QObject):
self.pattern_position = 0
self.last_step_time = time.time()
self.next_step_time = self.last_step_time + self.step_duration
# Synchronize volume patterns with arpeggiator start
self.volume_engine.sync_with_arpeggiator_start()
self.playing_state_changed.emit(True)
return True
return False
@ -494,7 +523,7 @@ class ArpeggiatorEngine(QObject):
# For directional patterns, adapt the pattern to fit the length
if self.pattern_type in ["up_down", "down_up"] and self.user_pattern_length >= 4:
# For up_down with 4 steps: take first half up, second half down
scale_notes = self._generate_scale_notes()
scale_notes = self._get_all_scale_notes()
half_length = self.user_pattern_length // 2
if self.pattern_type == "up_down":
@ -531,59 +560,132 @@ class ArpeggiatorEngine(QObject):
self.pattern_length = len(self.current_pattern)
self.pattern_position = 0
def _generate_scale_notes(self) -> List[int]:
"""Generate all scale notes within octave range"""
def _generate_scale_notes_up(self) -> List[int]:
"""Generate scale notes going upward from the selected starting note"""
scale_intervals = self.SCALES[self.scale]
notes = []
# Start from root note
# Calculate the starting note based on root note and scale_note_start
base_octave = self.root_note // 12
root_in_octave = self.root_note % 12
# Find closest scale degree to root
closest_degree = 0
min_distance = 12
for i, interval in enumerate(scale_intervals):
distance = abs((root_in_octave - interval) % 12)
if distance < min_distance:
min_distance = distance
closest_degree = i
# Get the interval for the selected scale degree
start_degree = self.scale_note_start % len(scale_intervals)
start_interval = scale_intervals[start_degree]
starting_note = base_octave * 12 + root_in_octave + start_interval
# Generate notes across octave range
for octave in range(self.octave_range):
for degree, interval in enumerate(scale_intervals):
note = base_octave * 12 + root_in_octave + interval + (octave * 12)
if 0 <= note <= 127:
notes.append(note)
# Generate notes going upward for octave_range octaves
current_note = starting_note
notes_per_octave = len(scale_intervals)
total_notes_needed = notes_per_octave * self.octave_range
return sorted(notes)
for i in range(total_notes_needed):
if 0 <= current_note <= 127:
notes.append(current_note)
# Move to next scale degree
current_degree = (start_degree + i + 1) % notes_per_octave
if current_degree == 0: # Wrapped to next octave
base_octave += 1
next_interval = scale_intervals[current_degree]
current_note = base_octave * 12 + root_in_octave + next_interval
return notes
def _generate_scale_notes_down(self) -> List[int]:
"""Generate scale notes going downward from the selected starting note"""
scale_intervals = self.SCALES[self.scale]
notes = []
# Calculate the starting note based on root note and scale_note_start
base_octave = self.root_note // 12
root_in_octave = self.root_note % 12
# Get the interval for the selected scale degree
start_degree = self.scale_note_start % len(scale_intervals)
start_interval = scale_intervals[start_degree]
starting_note = base_octave * 12 + root_in_octave + start_interval
# Generate notes going downward for octave_range octaves
current_note = starting_note
notes_per_octave = len(scale_intervals)
total_notes_needed = notes_per_octave * self.octave_range
for i in range(total_notes_needed):
if 0 <= current_note <= 127:
notes.append(current_note)
# Calculate next degree going down
next_degree = (start_degree - i - 1) % notes_per_octave
# Check if we need to move to previous octave
# This happens when we wrap around from a lower degree to a higher degree
if i == 0:
# First step down from starting note
if next_degree > start_degree: # Wrapped around (e.g., from 0 to 6)
base_octave -= 1
else:
# Subsequent steps - check if we wrapped around
prev_degree = (start_degree - i) % notes_per_octave
if next_degree > prev_degree: # Wrapped around
base_octave -= 1
current_degree = next_degree
next_interval = scale_intervals[current_degree]
current_note = base_octave * 12 + root_in_octave + next_interval
return notes
def _get_all_scale_notes(self) -> List[int]:
"""Get all available scale notes in both directions for patterns that need full range"""
up_notes = self._generate_scale_notes_up()
down_notes = self._generate_scale_notes_down()
# Combine and remove duplicates while preserving order
all_notes = []
seen = set()
# Add down notes (excluding starting note)
for note in reversed(down_notes[1:]):
if note not in seen:
all_notes.append(note)
seen.add(note)
# Add up notes (including starting note)
for note in up_notes:
if note not in seen:
all_notes.append(note)
seen.add(note)
return sorted(all_notes) # Return sorted for consistency
def _generate_up_pattern(self) -> List[int]:
"""Generate ascending arpeggio pattern"""
scale_notes = self._generate_scale_notes()
return scale_notes
return self._generate_scale_notes_up()
def _generate_down_pattern(self) -> List[int]:
"""Generate descending arpeggio pattern"""
scale_notes = self._generate_scale_notes()
return list(reversed(scale_notes))
return self._generate_scale_notes_down()
def _generate_up_down_pattern(self) -> List[int]:
"""Generate up then down pattern"""
scale_notes = self._generate_scale_notes()
# Up, then down (avoiding duplicate at top)
return scale_notes + list(reversed(scale_notes[:-1]))
up_notes = self._generate_scale_notes_up()
down_notes = self._generate_scale_notes_down()
# Up, then down (avoiding duplicate at starting note)
return up_notes + down_notes[1:]
def _generate_down_up_pattern(self) -> List[int]:
"""Generate down then up pattern"""
scale_notes = self._generate_scale_notes()
# Down, then up (avoiding duplicate at bottom)
return list(reversed(scale_notes)) + scale_notes[1:]
down_notes = self._generate_scale_notes_down()
up_notes = self._generate_scale_notes_up()
# Down, then up (avoiding duplicate at starting note)
return down_notes + up_notes[1:]
def _generate_random_pattern(self) -> List[int]:
"""Generate random pattern from scale notes"""
import random
scale_notes = self._generate_scale_notes()
scale_notes = self._get_all_scale_notes()
pattern_length = max(8, len(scale_notes))
return [random.choice(scale_notes) for _ in range(pattern_length)]
@ -705,7 +807,6 @@ class ArpeggiatorEngine(QObject):
"""Main update loop - called frequently for timing precision"""
if not self.is_playing:
self.check_note_offs()
self.volume_engine.update_pattern(0.016) # ~60fps
return
current_time = time.time()
@ -720,9 +821,6 @@ class ArpeggiatorEngine(QObject):
# Process scheduled delays
self.process_delays()
# Update volume patterns
self.volume_engine.update_pattern(0.016)
def process_step(self):
"""Process the current arpeggio step"""
@ -748,6 +846,9 @@ class ArpeggiatorEngine(QObject):
note_duration = self.step_duration * self.gate
note_end_time = time.time() + note_duration + swing_offset
# Update volume pattern position to sync with arpeggiator step
self.volume_engine.update_pattern_step(self.current_step, self.pattern_length)
# Calculate and set volume for this channel (once per note)
active_channel_count = len(set(self.channel_manager.active_voices.keys()))
if active_channel_count == 0:
@ -838,6 +939,12 @@ class ArpeggiatorEngine(QObject):
self.armed_channel_distribution = None
changes_applied = True
# Apply armed scale note start
if self.armed_scale_note_start is not None:
self.scale_note_start = self.armed_scale_note_start
self.armed_scale_note_start = None
changes_applied = True
# Apply armed preset
if self.armed_preset_data is not None and self.preset_apply_callback:
try:
@ -855,6 +962,7 @@ class ArpeggiatorEngine(QObject):
self.armed_timeout.stop() # Stop timeout timer since changes were applied
self.regenerate_pattern()
self.armed_state_changed.emit()
self.settings_changed.emit() # Update GUI to reflect new settings
def check_note_offs(self):
"""Check for notes that should be turned off"""

88
core/volume_pattern_engine.py

@ -22,9 +22,10 @@ class VolumePatternEngine(QObject):
# Pattern types available
PATTERN_TYPES = [
"static", "swell", "breathing", "wave", "build", "fade",
"pulse", "alternating", "stutter", "cascade", "ripple",
"random_sparkle", "spotlight", "bounce_volume"
"static", "swell", "1_bar_swell", "2_bar_swell", "4_bar_swell", "8_bar_swell", "16_bar_swell",
"accent_2", "accent_3", "accent_4", "accent_5", "accent_6", "accent_7", "accent_8",
"build", "fade", "pulse", "alternating", "stutter", "cascade", "ripple",
"random", "spotlight", "bounce_volume"
]
def __init__(self):
@ -103,9 +104,16 @@ class VolumePatternEngine(QObject):
return self.velocity_ranges.get(channel, self.global_velocity_range)
def update_pattern(self, delta_time: float):
"""Update pattern position based on elapsed time"""
"""Update pattern position based on elapsed time (legacy method)"""
self.pattern_position += delta_time * self.pattern_speed
def update_pattern_step(self, step_number: int, pattern_length: int):
"""Update pattern position based on arpeggiator step (synchronized)"""
# Use step number as the position for perfect sync
self.pattern_position = step_number
# Update bar length to match pattern length
self.bar_length = pattern_length
def get_channel_volume(self, channel: int, active_channel_count: int = 8) -> float:
"""
Calculate current volume for a channel based on active pattern.
@ -151,23 +159,43 @@ class VolumePatternEngine(QObject):
if self.current_pattern == "static":
return 1.0
elif self.current_pattern == "swell":
# Gradual swell up and down - completes one cycle per bar
cycle_rate = (2 * math.pi) / self.bar_length # Complete cycle over bar length
elif "swell" in self.current_pattern:
# Parse multi-bar patterns like "1_bar_swell", "2_bar_swell", etc.
bars = 1 # Default to 1 bar
if "_bar_" in self.current_pattern:
# Extract number from patterns like "4_bar_swell"
parts = self.current_pattern.split("_")
try:
bars = int(parts[0]) # Extract number before "_bar_"
except (ValueError, IndexError):
bars = 1
elif self.current_pattern == "swell":
bars = 1 # Plain "swell" is 1 bar
# Complete one cycle over the specified number of bars
total_length = self.bar_length * bars
cycle_rate = (2 * math.pi) / total_length
cycle = math.sin(self.pattern_position * cycle_rate) * 0.5 + 0.5
return cycle
elif self.current_pattern == "breathing":
# Smooth breathing rhythm - 2 cycles per bar
breathing_rate = (4 * math.pi) / self.bar_length # Two breaths per bar
cycle = math.sin(self.pattern_position * breathing_rate) * 0.5 + 0.5
return 0.3 + cycle * 0.7 # Keep minimum at 30%
elif self.current_pattern == "wave":
# Sine wave across channels
phase_offset = (channel - 1) * (2 * math.pi / active_channel_count)
wave = math.sin(self.pattern_position + phase_offset) * 0.5 + 0.5
return wave
elif "accent_" in self.current_pattern:
# Accent every Nth note - extract N from pattern name
try:
accent_interval = int(self.current_pattern.split("_")[1]) # Extract N from "accent_N"
except (ValueError, IndexError):
accent_interval = 2 # Default to every 2nd note
# Check if current position is on an accent beat
step_in_pattern = int(self.pattern_position) % accent_interval
if step_in_pattern == 0:
return 1.0 # Max volume on accent
else:
return 0.5 # Half volume on other notes
elif self.current_pattern == "random":
# Random volume between min and max for each note
import random
return random.random()
elif self.current_pattern == "build":
# Gradual crescendo over bar length, then fade back
@ -207,9 +235,6 @@ class VolumePatternEngine(QObject):
# Ripple effect from center
return self._ripple_pattern(channel, active_channel_count)
elif self.current_pattern == "random_sparkle":
# Random sparkle effect
return self._random_sparkle_pattern(channel)
elif self.current_pattern == "spotlight":
# Spotlight effect - one channel bright, others dim
@ -238,21 +263,6 @@ class VolumePatternEngine(QObject):
ripple = math.sin(ripple_phase) * 0.5 + 0.5
return max(0.2, ripple)
def _random_sparkle_pattern(self, channel: int) -> float:
"""Random sparkle effect"""
# Update random state periodically
if int(self.pattern_position * 4) % 8 == 0:
self.random_states[channel] = random.random()
base_random = self.random_states[channel]
sparkle_threshold = 0.7
if base_random > sparkle_threshold:
# Sparkle! Add some randomness to timing
sparkle_intensity = (base_random - sparkle_threshold) / (1.0 - sparkle_threshold)
return 0.3 + sparkle_intensity * 0.7
else:
return 0.2 + base_random * 0.3
def _spotlight_pattern(self, channel: int, active_channel_count: int) -> float:
"""Spotlight effect - one channel bright, others dim"""
@ -288,4 +298,8 @@ class VolumePatternEngine(QObject):
self.pattern_position = 0.0
for channel in range(1, 17):
self.pattern_phases[channel] = random.random() * 2 * math.pi
self.random_states[channel] = random.random()
self.random_states[channel] = random.random()
def sync_with_arpeggiator_start(self):
"""Synchronize pattern start with arpeggiator start"""
self.pattern_position = 0.0

237
gui/arpeggiator_controls.py

@ -22,6 +22,7 @@ class ArpeggiatorControls(QWidget):
self.root_note_buttons = {}
self.octave_buttons = {}
self.scale_buttons = {}
self.scale_notes_buttons = {}
self.pattern_buttons = {}
self.distribution_buttons = {}
self.speed_buttons = {}
@ -40,6 +41,7 @@ class ArpeggiatorControls(QWidget):
self.armed_root_note_button = None
self.armed_octave_button = None
self.armed_scale_button = None
self.armed_scale_note_button = None
self.armed_pattern_button = None
self.armed_distribution_button = None
# Speed changes apply immediately - no armed state needed
@ -148,10 +150,22 @@ class ArpeggiatorControls(QWidget):
layout.addWidget(scales_widget)
# Scale notes selection
layout.addWidget(QLabel("Scale Notes:"))
scale_notes_widget = QWidget()
self.scale_notes_layout = QGridLayout(scale_notes_widget)
self.scale_notes_layout.setSpacing(2)
self.scale_notes_buttons = {}
self.current_scale_note_index = 0 # Start from root by default
# Initially populate with major scale notes
self.update_scale_notes_display()
layout.addWidget(scale_notes_widget)
# Octave range dropdown
layout.addWidget(QLabel("Octave Range:"))
self.octave_range_combo = QComboBox()
self.octave_range_combo.setFixedHeight(20)
self.octave_range_combo.setFixedHeight(30)
for i in range(1, 5):
self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}")
layout.addWidget(self.octave_range_combo)
@ -292,7 +306,7 @@ class ArpeggiatorControls(QWidget):
self.tempo_spin.setRange(40, 200)
self.tempo_spin.setValue(120)
self.tempo_spin.setSuffix(" BPM")
self.tempo_spin.setFixedHeight(20)
self.tempo_spin.setFixedHeight(30)
tempo_layout.addWidget(self.tempo_spin)
layout.addLayout(tempo_layout)
@ -327,7 +341,7 @@ class ArpeggiatorControls(QWidget):
self.gate_slider = QSlider(Qt.Horizontal)
self.gate_slider.setRange(10, 200)
self.gate_slider.setValue(100)
self.gate_slider.setFixedHeight(20)
self.gate_slider.setFixedHeight(25)
gate_layout.addWidget(self.gate_slider)
self.gate_label = QLabel("100%")
self.gate_label.setFixedWidth(40)
@ -340,7 +354,7 @@ class ArpeggiatorControls(QWidget):
self.swing_slider = QSlider(Qt.Horizontal)
self.swing_slider.setRange(-100, 100)
self.swing_slider.setValue(0)
self.swing_slider.setFixedHeight(20)
self.swing_slider.setFixedHeight(25)
swing_layout.addWidget(self.swing_slider)
self.swing_label = QLabel("0%")
self.swing_label.setFixedWidth(40)
@ -353,7 +367,7 @@ class ArpeggiatorControls(QWidget):
self.velocity_slider = QSlider(Qt.Horizontal)
self.velocity_slider.setRange(1, 127)
self.velocity_slider.setValue(80)
self.velocity_slider.setFixedHeight(20)
self.velocity_slider.setFixedHeight(25)
velocity_layout.addWidget(self.velocity_slider)
self.velocity_label = QLabel("80")
self.velocity_label.setFixedWidth(40)
@ -383,7 +397,7 @@ class ArpeggiatorControls(QWidget):
self.delay_length_spin.setRange(0, 8)
self.delay_length_spin.setValue(3)
self.delay_length_spin.setSuffix(" repeats")
self.delay_length_spin.setFixedHeight(20)
self.delay_length_spin.setFixedHeight(30)
self.delay_length_spin.setEnabled(False)
delay_length_layout.addWidget(self.delay_length_spin)
delay_layout.addLayout(delay_length_layout)
@ -432,7 +446,7 @@ class ArpeggiatorControls(QWidget):
self.delay_fade_slider = QSlider(Qt.Horizontal)
self.delay_fade_slider.setRange(10, 90)
self.delay_fade_slider.setValue(30) # 30% fade per repeat
self.delay_fade_slider.setFixedHeight(20)
self.delay_fade_slider.setFixedHeight(25)
self.delay_fade_slider.setEnabled(False)
delay_fade_layout.addWidget(self.delay_fade_slider)
@ -472,6 +486,7 @@ class ArpeggiatorControls(QWidget):
if hasattr(self.arpeggiator, 'armed_state_changed'):
self.arpeggiator.armed_state_changed.connect(self.update_armed_states)
self.arpeggiator.settings_changed.connect(self.update_gui_from_engine)
# Event handlers
def on_root_note_clicked(self, note_index):
@ -495,8 +510,14 @@ class ArpeggiatorControls(QWidget):
self.current_root_note = note_index
self.root_note_buttons[note_index].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
# Update scale notes display when root note changes
self.update_scale_notes_display()
if hasattr(self.arpeggiator, 'set_root_note'):
self.arpeggiator.set_root_note(midi_note)
# Update starting scale note position
self.update_arpeggiator_scale_note()
def on_octave_clicked(self, octave):
midi_note = octave * 12 + self.current_root_note
@ -519,8 +540,14 @@ class ArpeggiatorControls(QWidget):
self.current_octave = octave
self.octave_buttons[octave].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
# Update scale notes display when octave changes
self.update_scale_notes_display()
if hasattr(self.arpeggiator, 'set_root_note'):
self.arpeggiator.set_root_note(midi_note)
# Update starting scale note position
self.update_arpeggiator_scale_note()
def on_scale_clicked(self, scale):
if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing:
@ -541,8 +568,15 @@ class ArpeggiatorControls(QWidget):
self.current_scale = scale
self.scale_buttons[scale].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
# Update scale notes display when scale changes
self.current_scale_note_index = 0 # Reset to root when scale changes
self.update_scale_notes_display()
if hasattr(self.arpeggiator, 'set_scale'):
self.arpeggiator.set_scale(scale)
# Update starting scale note position
self.update_arpeggiator_scale_note()
def on_pattern_clicked(self, pattern):
if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing:
@ -740,6 +774,9 @@ class ArpeggiatorControls(QWidget):
self.current_root_note = note_index
btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
self.armed_root_note_button = None
# Update scale notes display when root note changes
self.update_scale_notes_display()
self.update_arpeggiator_scale_note() # Sync with engine
break
# Octave armed -> active
@ -751,6 +788,9 @@ class ArpeggiatorControls(QWidget):
self.current_octave = octave
btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
self.armed_octave_button = None
# Update scale notes display when octave changes
self.update_scale_notes_display()
self.update_arpeggiator_scale_note() # Sync with engine
break
# Scale armed -> active
@ -762,6 +802,10 @@ class ArpeggiatorControls(QWidget):
self.current_scale = scale
btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
self.armed_scale_button = None
# Update scale notes display when scale changes
self.current_scale_note_index = 0 # Reset to root when scale changes
self.update_scale_notes_display()
self.update_arpeggiator_scale_note() # Sync with engine
break
# Pattern armed -> active
@ -786,4 +830,181 @@ class ArpeggiatorControls(QWidget):
self.armed_distribution_button = None
break
# Speed changes apply immediately - no armed state needed
# Scale note armed -> active
if self.armed_scale_note_button and hasattr(self.arpeggiator, 'armed_scale_note_start') and self.arpeggiator.armed_scale_note_start is None:
for scale_note_index, btn in self.scale_notes_buttons.items():
if btn == self.armed_scale_note_button:
# Clear old active scale note
if self.current_scale_note_index in self.scale_notes_buttons:
self.scale_notes_buttons[self.current_scale_note_index].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
# Set new active scale note (orange -> blue)
self.current_scale_note_index = scale_note_index
btn.setStyleSheet("background: #0099ff; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #00bbff;")
self.armed_scale_note_button = None
break
# Speed changes apply immediately - no armed state needed
def update_gui_from_engine(self):
"""Update all GUI controls to match engine settings"""
try:
# Update scale buttons
if hasattr(self, 'scale_buttons'):
# Clear current scale styling
if hasattr(self, 'current_scale') and self.current_scale in self.scale_buttons:
self.scale_buttons[self.current_scale].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;")
# Set new active scale
self.current_scale = self.arpeggiator.scale
if self.current_scale in self.scale_buttons:
self.scale_buttons[self.current_scale].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
# Update pattern buttons
if hasattr(self, 'pattern_buttons'):
# Clear current pattern styling
if hasattr(self, 'current_pattern') and self.current_pattern in self.pattern_buttons:
self.pattern_buttons[self.current_pattern].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;")
# Set new active pattern
self.current_pattern = self.arpeggiator.pattern_type
if self.current_pattern in self.pattern_buttons:
self.pattern_buttons[self.current_pattern].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;")
# Update scale note buttons
if hasattr(self, 'scale_notes_buttons'):
# Clear current scale note styling
if hasattr(self, 'current_scale_note_index') and self.current_scale_note_index in self.scale_notes_buttons:
self.scale_notes_buttons[self.current_scale_note_index].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
# Set new active scale note
self.current_scale_note_index = getattr(self.arpeggiator, 'scale_note_start', 0)
if self.current_scale_note_index in self.scale_notes_buttons:
self.scale_notes_buttons[self.current_scale_note_index].setStyleSheet("background: #0099ff; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #00bbff;")
# Update note speed buttons
if hasattr(self, 'speed_buttons'):
# Clear current speed styling
if hasattr(self, 'current_speed') and self.current_speed in self.speed_buttons:
self.speed_buttons[self.current_speed].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
# Set new active speed
self.current_speed = self.arpeggiator.note_speed
if self.current_speed in self.speed_buttons:
self.speed_buttons[self.current_speed].setStyleSheet("background: #9933cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;")
# Update pattern length buttons
if hasattr(self, 'pattern_length_buttons'):
# Clear current pattern length styling
if hasattr(self, 'current_pattern_length') and self.current_pattern_length in self.pattern_length_buttons:
self.pattern_length_buttons[self.current_pattern_length].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
# Set new active pattern length
if hasattr(self.arpeggiator, 'user_pattern_length'):
self.current_pattern_length = self.arpeggiator.user_pattern_length
if self.current_pattern_length in self.pattern_length_buttons:
self.pattern_length_buttons[self.current_pattern_length].setStyleSheet("background: #cc6600; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ee8800;")
# Update delay controls
if hasattr(self, 'delay_enabled_checkbox'):
self.delay_enabled_checkbox.setChecked(self.arpeggiator.delay_enabled)
if hasattr(self, 'delay_length_spin'):
self.delay_length_spin.setValue(self.arpeggiator.delay_length)
if hasattr(self, 'delay_fade_slider'):
self.delay_fade_slider.setValue(int(self.arpeggiator.delay_fade * 100))
# Update sliders and spinboxes
if hasattr(self, 'gate_slider'):
self.gate_slider.setValue(int(self.arpeggiator.gate * 100))
if hasattr(self, 'swing_slider'):
self.swing_slider.setValue(int(self.arpeggiator.swing * 100))
if hasattr(self, 'velocity_slider'):
self.velocity_slider.setValue(self.arpeggiator.velocity)
if hasattr(self, 'octave_range_combo'):
self.octave_range_combo.setCurrentIndex(self.arpeggiator.octave_range - 1)
if hasattr(self, 'tempo_spin'):
self.tempo_spin.setValue(int(self.arpeggiator.tempo))
except Exception as e:
print(f"Error updating GUI from engine: {e}")
def update_scale_notes_display(self):
"""Update the scale notes buttons based on current root note and scale"""
# Clear existing buttons
for button in self.scale_notes_buttons.values():
button.deleteLater()
self.scale_notes_buttons.clear()
# Get the scale definition
scale_intervals = self.arpeggiator.SCALES.get(self.current_scale, [0, 2, 4, 5, 7, 9, 11])
# Note names for display
note_names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# Calculate the actual MIDI notes for this scale
root_midi = self.current_octave * 12 + self.current_root_note
scale_notes = []
for interval in scale_intervals:
scale_notes.append(root_midi + interval)
# Create buttons for each scale note
for i, midi_note in enumerate(scale_notes):
note_name = note_names[midi_note % 12]
octave = midi_note // 12
display_text = f"{note_name}{octave}"
btn = QPushButton(display_text)
btn.setFixedSize(50, 25)
btn.setCheckable(True)
btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
btn.clicked.connect(lambda checked, idx=i: self.on_scale_note_clicked(idx))
# Set first note (root) as selected by default
if i == self.current_scale_note_index:
btn.setChecked(True)
btn.setStyleSheet("background: #0099ff; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #00bbff;")
self.scale_notes_buttons[i] = btn
self.scale_notes_layout.addWidget(btn, 0, i)
def on_scale_note_clicked(self, scale_note_index):
"""Handle scale note selection with armed state support"""
if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing:
# ARMED STATE - button turns orange, waits for pattern end
if self.armed_scale_note_button:
# Reset previous armed button
old_armed_index = None
for idx, btn in self.scale_notes_buttons.items():
if btn == self.armed_scale_note_button:
old_armed_index = idx
break
if old_armed_index is not None:
if old_armed_index == self.current_scale_note_index:
# It was the current active note, make it blue again
self.armed_scale_note_button.setStyleSheet("background: #0099ff; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #00bbff;")
else:
# It was just armed, make it gray again
self.armed_scale_note_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
# Set new armed button to orange
self.armed_scale_note_button = self.scale_notes_buttons[scale_note_index]
self.scale_notes_buttons[scale_note_index].setStyleSheet("background: #ff8800; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;")
# Arm the scale note change in the engine
if hasattr(self.arpeggiator, 'arm_scale_note_start'):
self.arpeggiator.arm_scale_note_start(scale_note_index)
else:
# IMMEDIATE CHANGE - apply right away
old_index = self.current_scale_note_index
self.current_scale_note_index = scale_note_index
# Update button styling
if old_index in self.scale_notes_buttons:
self.scale_notes_buttons[old_index].setChecked(False)
self.scale_notes_buttons[old_index].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #555555;")
if scale_note_index in self.scale_notes_buttons:
self.scale_notes_buttons[scale_note_index].setChecked(True)
self.scale_notes_buttons[scale_note_index].setStyleSheet("background: #0099ff; color: white; font-size: 11px; font-weight: bold; padding: 0px; border: 1px solid #00bbff;")
# Update arpeggiator engine with new starting scale note
self.update_arpeggiator_scale_note()
def update_arpeggiator_scale_note(self):
"""Update the arpeggiator engine with the selected scale note starting position"""
if hasattr(self.arpeggiator, 'set_scale_note_start'):
self.arpeggiator.set_scale_note_start(self.current_scale_note_index)

2
gui/channel_controls.py

@ -48,6 +48,7 @@ class ChannelControls(QWidget):
layout.addWidget(QLabel("Global Instrument:"), 1, 0)
global_layout = QHBoxLayout()
self.global_instrument_combo = QComboBox()
self.global_instrument_combo.setMaxVisibleItems(15) # Show more items
self.populate_instrument_combo(self.global_instrument_combo)
self.apply_global_button = QPushButton("Apply to All")
global_layout.addWidget(self.global_instrument_combo)
@ -96,6 +97,7 @@ class ChannelControls(QWidget):
# Instrument selection
instrument_combo = QComboBox()
instrument_combo.setFixedWidth(200)
instrument_combo.setMaxVisibleItems(15) # Show more items
self.populate_instrument_combo(instrument_combo)
layout.addWidget(instrument_combo)

1
gui/output_controls.py

@ -75,6 +75,7 @@ class OutputControls(QWidget):
layout.addWidget(QLabel("MIDI Output:"), 0, 0)
self.midi_device_combo = QComboBox()
self.midi_device_combo.setMinimumWidth(200)
self.midi_device_combo.setMaxVisibleItems(10) # Show more items
layout.addWidget(self.midi_device_combo, 0, 1)
self.refresh_button = QPushButton("Refresh")

3
gui/preset_controls.py

@ -268,6 +268,9 @@ class PresetControls(QWidget):
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)}")

44
gui/volume_controls.py

@ -19,15 +19,17 @@ class VolumeControls(QWidget):
"2_bar_swell": "2 Bar Swell",
"4_bar_swell": "4 Bar Swell",
"8_bar_swell": "8 Bar Swell",
"1_bar_breathing": "1 Bar Breathing",
"2_bar_breathing": "2 Bar Breathing",
"4_bar_breathing": "4 Bar Breathing",
"1_bar_wave": "1 Bar Wave",
"2_bar_wave": "2 Bar Wave",
"4_bar_wave": "4 Bar Wave",
"16_bar_swell": "16 Bar Swell",
"accent_2": "Accent Every 2nd",
"accent_3": "Accent Every 3rd",
"accent_4": "Accent Every 4th",
"accent_5": "Accent Every 5th",
"accent_6": "Accent Every 6th",
"accent_7": "Accent Every 7th",
"accent_8": "Accent Every 8th",
"cascade_up": "Cascade Up",
"cascade_down": "Cascade Down",
"random_sparkle": "Random Sparkle"
"random": "Random"
}
def __init__(self, volume_engine):
@ -221,7 +223,8 @@ class VolumeControls(QWidget):
if pattern == "static":
self.volume_engine.set_pattern("static")
elif "swell" in pattern:
self.volume_engine.set_pattern("swell")
# Pass the full pattern name to the volume engine for multi-bar support
self.volume_engine.set_pattern(pattern)
# Set appropriate speed based on bar length
if "1_bar" in pattern:
self.volume_engine.set_pattern_speed(2.0) # Faster for 1 bar
@ -231,28 +234,17 @@ class VolumeControls(QWidget):
self.volume_engine.set_pattern_speed(0.5) # Slower for 4 bars
elif "8_bar" in pattern:
self.volume_engine.set_pattern_speed(0.25) # Very slow for 8 bars
elif "breathing" in pattern:
self.volume_engine.set_pattern("breathing")
if "1_bar" in pattern:
self.volume_engine.set_pattern_speed(2.0)
elif "2_bar" in pattern:
self.volume_engine.set_pattern_speed(1.0)
elif "4_bar" in pattern:
self.volume_engine.set_pattern_speed(0.5)
elif "wave" in pattern:
self.volume_engine.set_pattern("wave")
if "1_bar" in pattern:
self.volume_engine.set_pattern_speed(2.0)
elif "2_bar" in pattern:
self.volume_engine.set_pattern_speed(1.0)
elif "4_bar" in pattern:
self.volume_engine.set_pattern_speed(0.5)
elif "16_bar" in pattern:
self.volume_engine.set_pattern_speed(0.125) # Extra slow for 16 bars
elif "accent_" in pattern:
# Pass the full pattern name for accent patterns
self.volume_engine.set_pattern(pattern)
elif pattern == "random":
self.volume_engine.set_pattern("random")
elif pattern == "cascade_up":
self.volume_engine.set_pattern("cascade")
elif pattern == "cascade_down":
self.volume_engine.set_pattern("cascade")
elif pattern == "random_sparkle":
self.volume_engine.set_pattern("random_sparkle")
def set_active_pattern(self, pattern):
"""Set active pattern button"""

57
presets/butt 2.json

@ -0,0 +1,57 @@
{
"version": "1.0",
"timestamp": "2025-09-09T08:50:29.583440",
"arpeggiator": {
"root_note": 62,
"scale": "major",
"pattern_type": "down",
"octave_range": 1,
"note_speed": "1/4",
"gate": 0.71,
"swing": 0.0,
"velocity": 47,
"tempo": 120.0,
"pattern_length": 3,
"channel_distribution": "up",
"delay_enabled": false,
"delay_length": 3,
"delay_timing": "2/1T",
"delay_fade": 0.9
},
"channels": {
"active_synth_count": 3,
"channel_instruments": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 0,
"6": 0,
"7": 0,
"8": 0,
"9": 0,
"10": 0,
"11": 0,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0
}
},
"volume_patterns": {
"current_pattern": "accent_4",
"pattern_speed": 2.0,
"pattern_intensity": 1.0,
"global_volume_range": [
0.0,
1.0
],
"global_velocity_range": [
40,
127
],
"channel_volume_ranges": {},
"velocity_ranges": {}
}
}
Loading…
Cancel
Save