You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

650 lines
24 KiB

"""
Arpeggiator Engine Module
Core arpeggiator logic with FL Studio-style functionality.
Generates arpeggio patterns, handles timing, and integrates with routing and volume systems.
"""
import time
import math
import threading
from typing import Dict, List, Optional, Tuple, Set
from PyQt5.QtCore import QObject, pyqtSignal, QTimer
from .midi_channel_manager import MIDIChannelManager
from .synth_router import SynthRouter
from .volume_pattern_engine import VolumePatternEngine
from .output_manager import OutputManager
class ArpeggiatorEngine(QObject):
"""
Main arpeggiator engine with FL Studio-style functionality.
Handles pattern generation, timing, scale processing, and note scheduling.
"""
# Signals
note_triggered = pyqtSignal(int, int, int, float) # channel, note, velocity, duration
pattern_step = pyqtSignal(int) # current step
tempo_changed = pyqtSignal(float) # BPM
playing_state_changed = pyqtSignal(bool) # is_playing
armed_state_changed = pyqtSignal() # armed state changed
# Arpeggio pattern types (FL Studio style)
PATTERN_TYPES = [
"up", "down", "up_down", "down_up", "random",
"note_order", "chord", "random_chord", "custom"
]
# Channel distribution patterns (how notes are spread across channels)
CHANNEL_DISTRIBUTION_PATTERNS = [
"up", "down", "up_down", "bounce", "random", "cycle",
"alternating", "single_channel"
]
# Musical scales
SCALES = {
"chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
"major": [0, 2, 4, 5, 7, 9, 11],
"minor": [0, 2, 3, 5, 7, 8, 10],
"dorian": [0, 2, 3, 5, 7, 9, 10],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
"lydian": [0, 2, 4, 6, 7, 9, 11],
"mixolydian": [0, 2, 4, 5, 7, 9, 10],
"locrian": [0, 1, 3, 5, 6, 8, 10],
"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
"pentatonic_major": [0, 2, 4, 7, 9],
"pentatonic_minor": [0, 3, 5, 7, 10],
"blues": [0, 3, 5, 6, 7, 10]
}
# Note speeds (as fractions of a beat)
NOTE_SPEEDS = {
"1/32": 1/32, "1/16": 1/16, "1/8": 1/8, "1/4": 1/4,
"1/2": 1/2, "1/1": 1, "2/1": 2
}
def __init__(self, channel_manager: MIDIChannelManager, synth_router: SynthRouter,
volume_engine: VolumePatternEngine, output_manager: OutputManager):
super().__init__()
self.channel_manager = channel_manager
self.synth_router = synth_router
self.volume_engine = volume_engine
self.output_manager = output_manager
# Arpeggiator settings
self.root_note = 60 # Middle C
self.scale = "major"
self.pattern_type = "up"
self.octave_range = 1 # 1-4 octaves
self.note_speed = "1/8" # Note duration
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
# Channel distribution settings
self.channel_distribution = "up" # How notes are distributed across channels
self.channel_position = 0 # Current position in channel distribution pattern
# Armed state system for pattern-end changes
self.armed_root_note = None
self.armed_scale = None
self.armed_pattern_type = None
self.armed_channel_distribution = None
# Pattern loop tracking
self.pattern_loops_completed = 0
self.pattern_start_position = 0
# Playback state
self.is_playing = False
self.tempo = 120.0 # BPM
self.current_step = 0
self.pattern_length = 0
# Input notes (what's being held down)
self.held_notes: Set[int] = set()
self.input_chord: List[int] = []
# Generated pattern
self.current_pattern: List[int] = []
self.pattern_position = 0
# Timing
self.last_step_time = 0.0
self.step_duration = 0.0 # Seconds per step
self.next_step_time = 0.0
# Active notes tracking for note-off
self.active_notes: Dict[Tuple[int, int], float] = {} # (channel, note) -> end_time
# Threading for timing precision
self.timing_thread = None
self.stop_timing = False
# Setup timing calculation
self.calculate_step_duration()
# Setup update timer
self.update_timer = QTimer()
self.update_timer.timeout.connect(self.update)
self.update_timer.start(1) # 1ms updates for precise timing
def set_root_note(self, note: int):
"""Set root note (0-127)"""
if 0 <= note <= 127:
self.root_note = note
self.regenerate_pattern()
def arm_root_note(self, note: int):
"""Arm a root note to change at pattern end"""
if 0 <= note <= 127:
self.armed_root_note = note
self.armed_state_changed.emit()
def clear_armed_root_note(self):
"""Clear armed root note"""
self.armed_root_note = None
self.armed_state_changed.emit()
def set_scale(self, scale_name: str):
"""Set musical scale"""
if scale_name in self.SCALES:
self.scale = scale_name
self.regenerate_pattern()
def arm_scale(self, scale_name: str):
"""Arm a scale to change at pattern end"""
if scale_name in self.SCALES:
self.armed_scale = scale_name
self.armed_state_changed.emit()
def clear_armed_scale(self):
"""Clear armed scale"""
self.armed_scale = None
self.armed_state_changed.emit()
def set_pattern_type(self, pattern_type: str):
"""Set arpeggio pattern type"""
if pattern_type in self.PATTERN_TYPES:
self.pattern_type = pattern_type
self.regenerate_pattern()
def arm_pattern_type(self, pattern_type: str):
"""Arm a pattern type to change at pattern end"""
if pattern_type in self.PATTERN_TYPES:
self.armed_pattern_type = pattern_type
self.armed_state_changed.emit()
def clear_armed_pattern_type(self):
"""Clear armed pattern type"""
self.armed_pattern_type = None
self.armed_state_changed.emit()
def set_octave_range(self, octaves: int):
"""Set octave range (1-4)"""
if 1 <= octaves <= 4:
self.octave_range = octaves
self.regenerate_pattern()
def set_note_speed(self, speed: str):
"""Set note speed"""
if speed in self.NOTE_SPEEDS:
self.note_speed = speed
self.calculate_step_duration()
def set_gate(self, gate: float):
"""Set gate (note length) 0.1-2.0"""
self.gate = max(0.1, min(2.0, gate))
def set_swing(self, swing: float):
"""Set swing amount -1.0 to 1.0"""
self.swing = max(-1.0, min(1.0, swing))
def set_velocity(self, velocity: int):
"""Set base velocity 0-127"""
self.velocity = max(0, min(127, velocity))
def set_channel_distribution(self, distribution: str):
"""Set channel distribution pattern"""
if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS:
self.channel_distribution = distribution
self.channel_position = 0 # Reset position
def arm_channel_distribution(self, distribution: str):
"""Arm a distribution pattern to change at pattern end"""
if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS:
self.armed_channel_distribution = distribution
self.armed_state_changed.emit()
def clear_armed_channel_distribution(self):
"""Clear armed distribution pattern"""
self.armed_channel_distribution = None
self.armed_state_changed.emit()
def set_tempo(self, bpm: float):
"""Set tempo in BPM"""
if 40 <= bpm <= 200:
self.tempo = bpm
self.calculate_step_duration()
self.tempo_changed.emit(bpm)
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 note_on(self, note: int):
"""Register a note being pressed"""
self.held_notes.add(note)
self.update_input_chord()
self.regenerate_pattern()
def note_off(self, note: int):
"""Register a note being released"""
self.held_notes.discard(note)
self.update_input_chord()
if not self.held_notes:
self.stop()
else:
self.regenerate_pattern()
def update_input_chord(self):
"""Update the chord from held notes"""
self.input_chord = sorted(list(self.held_notes))
def start(self):
"""Start arpeggiator playback"""
if not self.held_notes:
return False
if not self.is_playing:
self.is_playing = True
self.current_step = 0
self.pattern_position = 0
self.last_step_time = time.time()
self.next_step_time = self.last_step_time + self.step_duration
self.playing_state_changed.emit(True)
return True
return False
def stop(self):
"""Stop arpeggiator playback"""
if self.is_playing:
self.is_playing = False
self.all_notes_off()
self.playing_state_changed.emit(False)
def all_notes_off(self):
"""Send note off for all active notes"""
current_time = time.time()
for (channel, note) in list(self.active_notes.keys()):
self.output_manager.send_note_off(channel, note)
self.channel_manager.release_voice(channel, note)
self.active_notes.clear()
def regenerate_pattern(self):
"""Regenerate arpeggio pattern based on current settings"""
if not self.input_chord:
self.current_pattern = []
return
# Get scale notes for the key
scale_intervals = self.SCALES[self.scale]
# Generate pattern based on type
if self.pattern_type == "up":
self.current_pattern = self._generate_up_pattern()
elif self.pattern_type == "down":
self.current_pattern = self._generate_down_pattern()
elif self.pattern_type == "up_down":
self.current_pattern = self._generate_up_down_pattern()
elif self.pattern_type == "down_up":
self.current_pattern = self._generate_down_up_pattern()
elif self.pattern_type == "random":
self.current_pattern = self._generate_random_pattern()
elif self.pattern_type == "note_order":
self.current_pattern = self._generate_note_order_pattern()
elif self.pattern_type == "chord":
self.current_pattern = self._generate_chord_pattern()
elif self.pattern_type == "random_chord":
self.current_pattern = self._generate_random_chord_pattern()
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"""
scale_intervals = self.SCALES[self.scale]
notes = []
# Start from root note
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
# 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)
return sorted(notes)
def _generate_up_pattern(self) -> List[int]:
"""Generate ascending arpeggio pattern"""
scale_notes = self._generate_scale_notes()
return scale_notes
def _generate_down_pattern(self) -> List[int]:
"""Generate descending arpeggio pattern"""
scale_notes = self._generate_scale_notes()
return list(reversed(scale_notes))
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]))
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:]
def _generate_random_pattern(self) -> List[int]:
"""Generate random pattern from scale notes"""
import random
scale_notes = self._generate_scale_notes()
pattern_length = max(8, len(scale_notes))
return [random.choice(scale_notes) for _ in range(pattern_length)]
def _generate_note_order_pattern(self) -> List[int]:
"""Generate pattern in the order notes were played"""
return self.input_chord * self.octave_range
def _generate_chord_pattern(self) -> List[int]:
"""Generate chord pattern (all notes together, represented as sequence)"""
return self.input_chord
def _generate_random_chord_pattern(self) -> List[int]:
"""Generate pattern with random chord combinations"""
import random
import itertools
if len(self.input_chord) < 2:
return self.input_chord
# Generate various chord combinations
patterns = []
# Individual notes
patterns.extend(self.input_chord)
# Pairs
for pair in itertools.combinations(self.input_chord, 2):
patterns.extend(pair)
# Full chord
patterns.extend(self.input_chord)
return patterns
def _get_next_channel(self) -> int:
"""Get next channel based on distribution pattern"""
active_channels = self.channel_manager.get_active_channels()
if not active_channels:
return 1
if self.channel_distribution == "single_channel":
return active_channels[0]
elif self.channel_distribution == "up":
# 1, 2, 3, 4, 5, 6...
channel_idx = self.channel_position % len(active_channels)
self.channel_position += 1
return active_channels[channel_idx]
elif self.channel_distribution == "down":
# 6, 5, 4, 3, 2, 1...
channel_idx = (len(active_channels) - 1 - self.channel_position) % len(active_channels)
self.channel_position += 1
return active_channels[channel_idx]
elif self.channel_distribution == "up_down":
# 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3...
cycle_length = (len(active_channels) - 1) * 2
pos = self.channel_position % cycle_length
if pos < len(active_channels):
channel_idx = pos
else:
channel_idx = len(active_channels) - 2 - (pos - len(active_channels))
self.channel_position += 1
return active_channels[max(0, channel_idx)]
elif self.channel_distribution == "bounce":
# 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3... (same as up_down but clearer name)
return self._get_bounce_channel(active_channels)
elif self.channel_distribution == "random":
import random
return random.choice(active_channels)
elif self.channel_distribution == "cycle":
# Simple cycle through channels
channel_idx = self.channel_position % len(active_channels)
self.channel_position += 1
return active_channels[channel_idx]
elif self.channel_distribution == "alternating":
# Alternate between first and last, second and second-to-last, etc.
half_point = len(active_channels) // 2
if self.channel_position % 2 == 0:
# Even steps: use first half
idx = (self.channel_position // 2) % half_point
else:
# Odd steps: use second half (from end)
idx = len(active_channels) - 1 - ((self.channel_position // 2) % half_point)
self.channel_position += 1
return active_channels[idx]
# Default to up pattern
return active_channels[self.channel_position % len(active_channels)]
def _get_bounce_channel(self, active_channels: List[int]) -> int:
"""Get channel for bounce pattern with proper bounce logic"""
if len(active_channels) == 1:
return active_channels[0]
# Create bounce sequence: 0,1,2,3,2,1,0,1,2,3...
bounce_length = (len(active_channels) - 1) * 2
pos = self.channel_position % bounce_length
if pos < len(active_channels):
# Going up: 0,1,2,3
channel_idx = pos
else:
# Going down: 2,1,0 (skip the last one to avoid duplicate)
channel_idx = len(active_channels) - 2 - (pos - len(active_channels))
self.channel_position += 1
return active_channels[max(0, min(len(active_channels) - 1, channel_idx))]
def update(self):
"""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()
# Check if it's time for the next step
if current_time >= self.next_step_time and self.current_pattern:
self.process_step()
self.advance_step()
# Check for notes to turn off
self.check_note_offs()
# Update volume patterns
self.volume_engine.update_pattern(0.016)
def process_step(self):
"""Process the current arpeggio step"""
if not self.current_pattern:
return
# Get note from pattern
note = self.current_pattern[self.pattern_position]
# Apply swing
swing_offset = 0
if self.swing != 0 and self.current_step % 2 == 1:
swing_offset = self.step_duration * self.swing * 0.1
# Route note to appropriate channel using distribution pattern
target_channel = self._get_next_channel()
if target_channel:
# Use static velocity (not modified by volume patterns)
static_velocity = self.velocity
# Calculate note duration
note_duration = self.step_duration * self.gate
note_end_time = time.time() + note_duration + swing_offset
# 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:
active_channel_count = 1 # At least count the channel we're about to play on
volume = self.volume_engine.get_channel_volume(target_channel, active_channel_count)
midi_volume = int(volume * 127)
self.output_manager.send_volume_change(target_channel, midi_volume)
# Send note on
self.output_manager.send_note_on(target_channel, note, static_velocity)
# Schedule note off
self.active_notes[(target_channel, note)] = note_end_time
# Emit signal for GUI
self.note_triggered.emit(target_channel, note, static_velocity, note_duration)
def advance_step(self):
"""Advance to next step in pattern"""
old_pattern_position = self.pattern_position
self.pattern_position = (self.pattern_position + 1) % self.pattern_length
self.current_step += 1
# Check if pattern completed a full loop
if old_pattern_position != 0 and self.pattern_position == 0:
self.pattern_loops_completed += 1
self.apply_armed_changes()
# Calculate next step time with swing
base_time = self.next_step_time + self.step_duration
# Apply swing to next step if it's an off-beat
if self.swing != 0 and (self.current_step + 1) % 2 == 1:
swing_offset = self.step_duration * self.swing * 0.1
self.next_step_time = base_time + swing_offset
else:
self.next_step_time = base_time
self.pattern_step.emit(self.current_step)
def apply_armed_changes(self):
"""Apply armed changes at pattern end"""
changes_applied = False
# Apply armed root note
if self.armed_root_note is not None:
self.root_note = self.armed_root_note
self.armed_root_note = None
changes_applied = True
# Apply armed scale
if self.armed_scale is not None:
self.scale = self.armed_scale
self.armed_scale = None
changes_applied = True
# Apply armed pattern type
if self.armed_pattern_type is not None:
self.pattern_type = self.armed_pattern_type
self.armed_pattern_type = None
changes_applied = True
# Apply armed channel distribution
if self.armed_channel_distribution is not None:
self.channel_distribution = self.armed_channel_distribution
self.channel_position = 0 # Reset position
self.armed_channel_distribution = None
changes_applied = True
# If any changes were applied, regenerate pattern and emit signal
if changes_applied:
self.regenerate_pattern()
self.armed_state_changed.emit()
def check_note_offs(self):
"""Check for notes that should be turned off"""
current_time = time.time()
notes_to_remove = []
channels_becoming_inactive = set()
for (channel, note), end_time in self.active_notes.items():
if current_time >= end_time:
self.output_manager.send_note_off(channel, note)
self.channel_manager.release_voice(channel, note)
notes_to_remove.append((channel, note))
# Check if this channel will have no more active notes
remaining_notes_on_channel = [k for k in self.active_notes.keys() if k[0] == channel and k != (channel, note)]
if not remaining_notes_on_channel:
channels_becoming_inactive.add(channel)
for key in notes_to_remove:
del self.active_notes[key]
# Dim 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
def get_current_state(self) -> Dict:
"""Get current arpeggiator state"""
return {
'is_playing': self.is_playing,
'root_note': self.root_note,
'scale': self.scale,
'pattern_type': self.pattern_type,
'octave_range': self.octave_range,
'note_speed': self.note_speed,
'gate': self.gate,
'swing': self.swing,
'velocity': self.velocity,
'tempo': self.tempo,
'current_step': self.current_step,
'pattern_position': self.pattern_position,
'pattern_length': self.pattern_length,
'held_notes': list(self.held_notes),
'current_pattern': self.current_pattern.copy()
}