diff --git a/core/arpeggiator_engine.py b/core/arpeggiator_engine.py index 86259fd..b64b902 100644 --- a/core/arpeggiator_engine.py +++ b/core/arpeggiator_engine.py @@ -102,6 +102,15 @@ class ArpeggiatorEngine(QObject): self.tempo = 120.0 # BPM self.current_step = 0 self.pattern_length = 0 + self.user_pattern_length = 8 # User-settable pattern length (1-16) + + # Delay/Echo settings + self.delay_enabled = False + self.delay_length = 3 # Number of repeats (0-8) + self.delay_timing = "1/8" # Timing between delays + self.delay_fade = 0.3 # Volume fade per repeat (0.0-1.0) + self.delay_step_duration = 0.0 # Calculated delay timing + self.scheduled_delays = [] # List of (time, channel, note, volume) tuples # Input notes (what's being held down) self.held_notes: Set[int] = set() @@ -125,6 +134,7 @@ class ArpeggiatorEngine(QObject): # Setup timing calculation self.calculate_step_duration() + self.calculate_delay_step_duration() # Setup update timer self.update_timer = QTimer() @@ -193,6 +203,9 @@ 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() def set_gate(self, gate: float): """Set gate (note length) 0.1-2.0""" @@ -228,14 +241,106 @@ class ArpeggiatorEngine(QObject): if 40 <= bpm <= 200: self.tempo = bpm self.calculate_step_duration() + self.calculate_delay_step_duration() self.tempo_changed.emit(bpm) + def set_pattern_length(self, length: int): + """Set user-defined pattern length""" + if 1 <= length <= 16: + self.user_pattern_length = length + self.regenerate_pattern() + + def set_delay_enabled(self, enabled: bool): + """Enable or disable delay/echo""" + self.delay_enabled = enabled + if not enabled: + self.scheduled_delays.clear() + + def set_delay_length(self, length: int): + """Set number of delay repeats""" + if 0 <= length <= 8: + self.delay_length = length + + def set_delay_timing(self, timing: str): + """Set delay timing""" + if timing in self.NOTE_SPEEDS: + self.delay_timing = timing + self.calculate_delay_step_duration() + + def set_delay_fade(self, fade: float): + """Set delay fade amount (0.0 to 1.0)""" + self.delay_fade = max(0.0, min(1.0, fade)) + def calculate_step_duration(self): """Calculate time between steps based on tempo and note speed""" beats_per_second = self.tempo / 60.0 note_duration = self.NOTE_SPEEDS[self.note_speed] 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""" + beats_per_second = self.tempo / 60.0 + delay_note_duration = self.NOTE_SPEEDS[self.delay_timing] + self.delay_step_duration = delay_note_duration / beats_per_second + + def schedule_delays(self, channel: int, note: int, original_volume: int): + """Schedule delay/echo repeats for a note""" + current_time = time.time() + current_volume = original_volume + + for delay_step in range(1, self.delay_length + 1): + # Calculate delay time + delay_time = current_time + (delay_step * self.delay_step_duration) + + # Calculate faded volume for this delay step + fade_factor = (1.0 - self.delay_fade) ** delay_step + delayed_volume = int(current_volume * fade_factor) + + # Don't schedule if volume becomes too quiet + if delayed_volume < 5: + break + + # Schedule the delayed note + self.scheduled_delays.append({ + 'time': delay_time, + 'channel': channel, + 'note': note, + 'volume': delayed_volume, + 'velocity': self.velocity, # Use original velocity + 'duration': self.step_duration * self.gate + }) + + def process_delays(self): + """Process scheduled delay/echo notes""" + current_time = time.time() + delays_to_remove = [] + + for i, delay in enumerate(self.scheduled_delays): + if current_time >= delay['time']: + # Time to play this delayed note + channel = delay['channel'] + note = delay['note'] + volume = delay['volume'] + velocity = delay['velocity'] + duration = delay['duration'] + + # Set the faded volume + self.output_manager.send_volume_change(channel, volume) + + # Send the delayed note + self.output_manager.send_note_on(channel, note, velocity) + + # Schedule note off for delayed note + note_end_time = current_time + duration + self.active_notes[(channel, note)] = note_end_time + + # Mark for removal + delays_to_remove.append(i) + + # Remove processed delays (in reverse order to maintain indices) + for i in reversed(delays_to_remove): + del self.scheduled_delays[i] + def note_on(self, note: int): """Register a note being pressed""" self.held_notes.add(note) @@ -312,6 +417,18 @@ class ArpeggiatorEngine(QObject): elif self.pattern_type == "random_chord": self.current_pattern = self._generate_random_chord_pattern() + # Apply user pattern length by truncating or repeating the pattern + if self.current_pattern: + original_pattern = self.current_pattern.copy() + if len(self.current_pattern) > self.user_pattern_length: + # Truncate pattern to user length + self.current_pattern = self.current_pattern[:self.user_pattern_length] + elif len(self.current_pattern) < self.user_pattern_length: + # Repeat pattern to fill user length + while len(self.current_pattern) < self.user_pattern_length: + remaining_steps = self.user_pattern_length - len(self.current_pattern) + self.current_pattern.extend(original_pattern[:remaining_steps]) + self.pattern_length = len(self.current_pattern) self.pattern_position = 0 @@ -502,6 +619,9 @@ class ArpeggiatorEngine(QObject): # Check for notes to turn off self.check_note_offs() + # Process scheduled delays + self.process_delays() + # Update volume patterns self.volume_engine.update_pattern(0.016) @@ -541,6 +661,10 @@ class ArpeggiatorEngine(QObject): # Send note on self.output_manager.send_note_on(target_channel, note, static_velocity) + # Schedule delay/echo if enabled + if self.delay_enabled and self.delay_length > 0: + self.schedule_delays(target_channel, note, midi_volume) + # Schedule note off self.active_notes[(target_channel, note)] = note_end_time @@ -624,10 +748,10 @@ class ArpeggiatorEngine(QObject): for key in notes_to_remove: del self.active_notes[key] - # Dim visual display for channels that just became inactive + # Turn off visual display for channels that just became inactive for channel in channels_becoming_inactive: - # Send volume change signal with low volume for visual feedback - self.output_manager.volume_sent.emit(channel, 20) # Dim display + # Send volume change signal with 0 volume for black display + self.output_manager.volume_sent.emit(channel, 0) # Black display def get_current_state(self) -> Dict: """Get current arpeggiator state""" diff --git a/gui/arpeggiator_controls.py b/gui/arpeggiator_controls.py index da736f2..57346c7 100644 --- a/gui/arpeggiator_controls.py +++ b/gui/arpeggiator_controls.py @@ -25,6 +25,7 @@ class ArpeggiatorControls(QWidget): self.pattern_buttons = {} self.distribution_buttons = {} self.speed_buttons = {} + self.pattern_length_buttons = {} self.current_root_note = 0 self.current_octave = 4 @@ -32,6 +33,8 @@ class ArpeggiatorControls(QWidget): self.current_pattern = "up" self.current_distribution = "up" self.current_speed = "1/8" + self.current_pattern_length = 8 + self.current_delay_timing = "1/8" # Armed state tracking self.armed_root_note_button = None @@ -248,6 +251,31 @@ class ArpeggiatorControls(QWidget): layout.addWidget(pattern_widget) + # Pattern length buttons + layout.addWidget(QLabel("Pattern Length:")) + length_widget = QWidget() + length_layout = QGridLayout(length_widget) + length_layout.setSpacing(0) # NO horizontal spacing + length_layout.setVerticalSpacing(2) # Minimal vertical spacing + length_layout.setContentsMargins(0, 0, 0, 0) + + self.pattern_length_buttons = {} + for i in range(1, 17): # 1-16 pattern lengths + btn = QPushButton(str(i)) + btn.setFixedSize(30, 22) # Smaller buttons for numbers + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, length=i: self.on_pattern_length_clicked(length)) + + if i == 8: # Default to 8 + btn.setChecked(True) + btn.setStyleSheet("background: #cc6600; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ee8800;") + + self.pattern_length_buttons[i] = btn + length_layout.addWidget(btn, i // 8, i % 8) # 2 rows of 8 + + layout.addWidget(length_widget) + return group def timing_quadrant(self): @@ -332,6 +360,87 @@ class ArpeggiatorControls(QWidget): velocity_layout.addWidget(self.velocity_label) layout.addLayout(velocity_layout) + # Delay/Echo controls + delay_layout = QVBoxLayout() + + # Delay toggle + self.delay_enabled = False + delay_toggle_layout = QHBoxLayout() + delay_toggle_layout.addWidget(QLabel("Delay/Echo:")) + self.delay_toggle = QPushButton("OFF") + self.delay_toggle.setFixedSize(50, 20) + self.delay_toggle.setCheckable(True) + self.delay_toggle.setStyleSheet("background: #5a2d2d; color: white; font-size: 10px; font-weight: bold;") + self.delay_toggle.clicked.connect(self.on_delay_toggle) + delay_toggle_layout.addWidget(self.delay_toggle) + delay_toggle_layout.addStretch() + delay_layout.addLayout(delay_toggle_layout) + + # Delay length (0-8 repeats) + delay_length_layout = QHBoxLayout() + delay_length_layout.addWidget(QLabel("Delay Length:")) + self.delay_length_spin = QSpinBox() + 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.setEnabled(False) + delay_length_layout.addWidget(self.delay_length_spin) + delay_layout.addLayout(delay_length_layout) + + # Delay timing buttons (same as note speed) + delay_timing_label = QLabel("Delay Timing:") + delay_timing_label.setEnabled(False) + delay_layout.addWidget(delay_timing_label) + self.delay_timing_label = delay_timing_label + + delay_timing_widget = QWidget() + delay_timing_layout = QHBoxLayout(delay_timing_widget) + delay_timing_layout.setSpacing(0) + delay_timing_layout.setContentsMargins(0, 0, 0, 0) + + self.delay_timing_buttons = {} + delay_speeds = ["1/32", "1/16", "1/8", "1/4", "1/2", "1/1"] + for speed in delay_speeds: + btn = QPushButton(speed) + btn.setFixedSize(40, 18) + btn.setCheckable(True) + btn.setStyleSheet("background: #2a2a2a; color: #666666; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #333333;") + btn.setEnabled(False) + btn.clicked.connect(lambda checked, s=speed: self.on_delay_timing_clicked(s)) + + if speed == "1/8": + btn.setChecked(True) + + self.delay_timing_buttons[speed] = btn + delay_timing_layout.addWidget(btn) + + delay_timing_widget.setEnabled(False) + self.delay_timing_widget = delay_timing_widget + delay_layout.addWidget(delay_timing_widget) + + # Delay fade slider (percentage) + delay_fade_layout = QHBoxLayout() + delay_fade_label = QLabel("Delay Fade:") + delay_fade_label.setEnabled(False) + delay_fade_layout.addWidget(delay_fade_label) + self.delay_fade_label = delay_fade_label + + 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.setEnabled(False) + delay_fade_layout.addWidget(self.delay_fade_slider) + + self.delay_fade_value = QLabel("30%") + self.delay_fade_value.setFixedWidth(40) + self.delay_fade_value.setEnabled(False) + delay_fade_layout.addWidget(self.delay_fade_value) + delay_layout.addLayout(delay_fade_layout) + + layout.addLayout(delay_layout) + # Presets preset_layout = QHBoxLayout() self.save_btn = QPushButton("Save Preset") @@ -352,6 +461,8 @@ class ArpeggiatorControls(QWidget): 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.delay_length_spin.valueChanged.connect(self.on_delay_length_changed) + self.delay_fade_slider.valueChanged.connect(self.on_delay_fade_changed) self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) self.save_btn.clicked.connect(self.save_preset) self.load_btn.clicked.connect(self.load_preset) @@ -485,6 +596,80 @@ class ArpeggiatorControls(QWidget): if hasattr(self.arpeggiator, 'set_note_speed'): self.arpeggiator.set_note_speed(speed) + def on_pattern_length_clicked(self, length): + # Pattern length changes apply immediately + if 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;") + + self.current_pattern_length = length + self.pattern_length_buttons[length].setStyleSheet("background: #cc6600; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ee8800;") + + if hasattr(self.arpeggiator, 'set_pattern_length'): + self.arpeggiator.set_pattern_length(length) + + def on_delay_toggle(self): + """Handle delay on/off toggle""" + self.delay_enabled = self.delay_toggle.isChecked() + + if self.delay_enabled: + self.delay_toggle.setText("ON") + self.delay_toggle.setStyleSheet("background: #2d5a2d; color: white; font-size: 10px; font-weight: bold;") + # Enable all delay controls + self.delay_length_spin.setEnabled(True) + self.delay_timing_label.setEnabled(True) + self.delay_timing_widget.setEnabled(True) + self.delay_fade_label.setEnabled(True) + self.delay_fade_slider.setEnabled(True) + self.delay_fade_value.setEnabled(True) + + # Enable timing buttons and update their style + for btn in self.delay_timing_buttons.values(): + btn.setEnabled(True) + if btn.isChecked(): + btn.setStyleSheet("background: #9933cc; color: white; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + else: + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + else: + self.delay_toggle.setText("OFF") + self.delay_toggle.setStyleSheet("background: #5a2d2d; color: white; font-size: 10px; font-weight: bold;") + # Disable all delay controls + self.delay_length_spin.setEnabled(False) + self.delay_timing_label.setEnabled(False) + self.delay_timing_widget.setEnabled(False) + self.delay_fade_label.setEnabled(False) + self.delay_fade_slider.setEnabled(False) + self.delay_fade_value.setEnabled(False) + + # Disable timing buttons and dim their style + for btn in self.delay_timing_buttons.values(): + btn.setEnabled(False) + btn.setStyleSheet("background: #2a2a2a; color: #666666; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #333333;") + + if hasattr(self.arpeggiator, 'set_delay_enabled'): + self.arpeggiator.set_delay_enabled(self.delay_enabled) + + def on_delay_timing_clicked(self, timing): + """Handle delay timing button clicks""" + if self.current_delay_timing in self.delay_timing_buttons: + self.delay_timing_buttons[self.current_delay_timing].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.current_delay_timing = timing + self.delay_timing_buttons[timing].setStyleSheet("background: #9933cc; color: white; font-size: 10px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + + if hasattr(self.arpeggiator, 'set_delay_timing'): + self.arpeggiator.set_delay_timing(timing) + + def on_delay_length_changed(self, length): + """Handle delay length changes""" + if hasattr(self.arpeggiator, 'set_delay_length'): + self.arpeggiator.set_delay_length(length) + + def on_delay_fade_changed(self, fade_percent): + """Handle delay fade changes""" + self.delay_fade_value.setText(f"{fade_percent}%") + if hasattr(self.arpeggiator, 'set_delay_fade'): + self.arpeggiator.set_delay_fade(fade_percent / 100.0) # Convert to 0-1 range + @pyqtSlot(int) def on_tempo_changed(self, tempo): if hasattr(self.arpeggiator, 'set_tempo'): diff --git a/gui/simulator_display.py b/gui/simulator_display.py index 49e3efc..41b35ab 100644 --- a/gui/simulator_display.py +++ b/gui/simulator_display.py @@ -40,14 +40,15 @@ class SynthWidget(QFrame): painter.setRenderHint(QPainter.Antialiasing) # Calculate brightness based on MIDI volume (0-127) - brightness_intensity = int((self.channel_volume_midi / 127.0) * 255) + brightness_intensity = int((self.channel_volume_midi / 127.0) * 235) # Max 235 to leave room for +20 # Create color based on channel (different hues) hue = (self.channel - 1) * 360 / 16 # Distribute hues across spectrum - color = QColor.fromHsv(int(hue), 200, brightness_intensity + 20) + brightness_value = max(20, min(255, brightness_intensity + 20)) # Clamp to valid range + color = QColor.fromHsv(int(hue), 200, brightness_value) - # Draw volume-based lighting effect - if self.channel_volume_midi > 6: # Only show if volume is above ~5% (6/127) + # Draw volume-based lighting effect - only show if volume is above 0 + if self.channel_volume_midi > 0: # Draw volume glow glow_rect = self.rect().adjusted(8, 8, -8, -8) painter.setBrush(QBrush(color))