Browse Source

Add pattern length control and delay/echo functionality

Pattern Length Features:
- Add 1-16 pattern length buttons to Pattern Settings quadrant
- Implement user-settable pattern length that truncates/repeats generated patterns
- Default to 8 steps with immediate pattern regeneration on change

Delay/Echo Features:
- Add comprehensive delay/echo controls to Timing Settings
- Toggle ON/OFF with visual feedback and control enabling/disabling
- Configurable delay length (0-8 repeats) with volume fade per repeat
- Independent delay timing (1/32 to 1/1 note values)
- Delay fade slider (10-90%) for volume reduction per echo
- Delay notes scheduled and processed with decreasing volumes
- Delay notes play on same channels as original notes

Visual Display Improvements:
- Fix volume >100 color overflow issue with proper brightness clamping
- Inactive channels now display completely black (volume 0) instead of dim
- Improved volume threshold for visual feedback (only show when volume > 0)
- Better brightness scaling for MIDI volume values 0-127

Technical Implementation:
- Pattern length applied after pattern generation with smart truncation/repetition
- Delay scheduling system with fade calculations and timing precision
- Delay processing integrated into main update loop
- Proper cleanup of delay queue when disabled
- Volume and timing calculations for delayed notes

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

Co-Authored-By: Claude <noreply@anthropic.com>
master
melancholytron 2 months ago
parent
commit
20176722cd
  1. 130
      core/arpeggiator_engine.py
  2. 185
      gui/arpeggiator_controls.py
  3. 9
      gui/simulator_display.py

130
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"""

185
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'):

9
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))

Loading…
Cancel
Save