From f3161297317a63213d2b75644e8807f5168c5d08 Mon Sep 17 00:00:00 2001 From: melancholytron Date: Tue, 9 Sep 2025 10:46:52 -0500 Subject: [PATCH] Implement scale note selection and fix directional arpeggios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/arpeggiator_engine.py | 192 +++++++++++++++++++++------ core/volume_pattern_engine.py | 88 +++++++------ gui/arpeggiator_controls.py | 237 ++++++++++++++++++++++++++++++++-- gui/channel_controls.py | 2 + gui/output_controls.py | 1 + gui/preset_controls.py | 3 + gui/volume_controls.py | 44 +++---- presets/butt 2.json | 57 ++++++++ 8 files changed, 511 insertions(+), 113 deletions(-) create mode 100644 presets/butt 2.json diff --git a/core/arpeggiator_engine.py b/core/arpeggiator_engine.py index 93e0549..c6b159f 100644 --- a/core/arpeggiator_engine.py +++ b/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""" diff --git a/core/volume_pattern_engine.py b/core/volume_pattern_engine.py index c9a6d2e..849bdbe 100644 --- a/core/volume_pattern_engine.py +++ b/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() \ No newline at end of file + self.random_states[channel] = random.random() + + def sync_with_arpeggiator_start(self): + """Synchronize pattern start with arpeggiator start""" + self.pattern_position = 0.0 \ No newline at end of file diff --git a/gui/arpeggiator_controls.py b/gui/arpeggiator_controls.py index 87e2b7e..4a874cc 100644 --- a/gui/arpeggiator_controls.py +++ b/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 \ No newline at end of file + # 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) \ No newline at end of file diff --git a/gui/channel_controls.py b/gui/channel_controls.py index 224a668..aeca6cf 100644 --- a/gui/channel_controls.py +++ b/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) diff --git a/gui/output_controls.py b/gui/output_controls.py index e3a3932..ea0cb8f 100644 --- a/gui/output_controls.py +++ b/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") diff --git a/gui/preset_controls.py b/gui/preset_controls.py index 302ac87..36062c5 100644 --- a/gui/preset_controls.py +++ b/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)}") diff --git a/gui/volume_controls.py b/gui/volume_controls.py index a175f16..4ad141d 100644 --- a/gui/volume_controls.py +++ b/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""" diff --git a/presets/butt 2.json b/presets/butt 2.json new file mode 100644 index 0000000..61b8c57 --- /dev/null +++ b/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": {} + } +} \ No newline at end of file