""" Volume Pattern Engine Module Handles volume and velocity pattern generation for visual lighting effects. Creates dynamic volume patterns that control both audio levels and lighting brightness. """ import math import random from typing import Dict, List, Tuple, Optional from PyQt5.QtCore import QObject, pyqtSignal class VolumePatternEngine(QObject): """ Generates volume and velocity patterns for enhanced visual effects. Controls both MIDI channel volume (CC7) and note velocity for brightness control. """ # Signals for GUI updates pattern_changed = pyqtSignal(str) # pattern_name volume_updated = pyqtSignal(int, float) # channel, volume (0.0-1.0) # Pattern types available PATTERN_TYPES = [ "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): super().__init__() # Current pattern settings self.current_pattern = "static" self.pattern_speed = 1.0 # Speed multiplier self.pattern_intensity = 1.0 # Intensity multiplier # Position tracking for patterns self.pattern_position = 0.0 self.pattern_direction = 1 # 1 for forward, -1 for reverse self.bar_length = 8 # Pattern length defines a "bar" for volume patterns # Volume ranges per channel {channel: (min, max)} self.channel_volume_ranges: Dict[int, Tuple[float, float]] = {} self.velocity_ranges: Dict[int, Tuple[int, int]] = {} # Global ranges (applied to all channels if no individual range set) self.global_volume_range = (0.2, 1.0) # 20% to 100% self.global_velocity_range = (40, 127) # MIDI velocity range # Pattern state self.pattern_phases: Dict[int, float] = {} # Per-channel phase offsets self.random_states: Dict[int, float] = {} # For random patterns # Initialize random states for channels for channel in range(1, 17): self.pattern_phases[channel] = random.random() * 2 * math.pi self.random_states[channel] = random.random() def set_pattern(self, pattern_name: str) -> bool: """Set the current volume pattern""" if pattern_name in self.PATTERN_TYPES: self.current_pattern = pattern_name self.pattern_changed.emit(pattern_name) return True return False def set_pattern_speed(self, speed: float): """Set pattern speed multiplier (0.1 to 5.0)""" self.pattern_speed = max(0.1, min(5.0, speed)) def set_pattern_intensity(self, intensity: float): """Set pattern intensity multiplier (0.0 to 2.0)""" self.pattern_intensity = max(0.0, min(2.0, intensity)) def set_bar_length(self, length: int): """Set bar length (pattern length) for volume patterns""" self.bar_length = max(1, min(16, length)) def set_channel_volume_range(self, channel: int, min_vol: float, max_vol: float): """Set volume range for a specific channel (0.0 to 1.0)""" min_vol = max(0.0, min(1.0, min_vol)) max_vol = max(min_vol, min(1.0, max_vol)) self.channel_volume_ranges[channel] = (min_vol, max_vol) def set_velocity_range(self, channel: int, min_vel: int, max_vel: int): """Set velocity range for a specific channel (0-127)""" min_vel = max(0, min(127, min_vel)) max_vel = max(min_vel, min(127, max_vel)) self.velocity_ranges[channel] = (min_vel, max_vel) def set_global_ranges(self, min_vol: float, max_vol: float, min_vel: int, max_vel: int): """Set global volume and velocity ranges""" self.global_volume_range = (max(0.0, min(1.0, min_vol)), max(0.0, min(1.0, max_vol))) self.global_velocity_range = (max(0, min(127, min_vel)), max(0, min(127, max_vel))) def get_channel_volume_range(self, channel: int) -> Tuple[float, float]: """Get volume range for a channel (uses global if not set individually)""" return self.channel_volume_ranges.get(channel, self.global_volume_range) def get_velocity_range(self, channel: int) -> Tuple[int, int]: """Get velocity range for a channel (uses global if not set individually)""" return self.velocity_ranges.get(channel, self.global_velocity_range) def update_pattern(self, delta_time: float): """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. Returns volume as float 0.0 to 1.0 """ min_vol, max_vol = self.get_channel_volume_range(channel) # Get base pattern value (0.0 to 1.0) pattern_value = self._calculate_pattern_value(channel, active_channel_count) # Apply intensity pattern_value = pattern_value * self.pattern_intensity pattern_value = max(0.0, min(1.0, pattern_value)) # Scale to channel's volume range volume = min_vol + (max_vol - min_vol) * pattern_value return volume def get_note_velocity(self, base_velocity: int, channel: int, active_channel_count: int = 8) -> int: """ Calculate note velocity based on volume pattern and base velocity. Returns MIDI velocity (0-127) """ min_vel, max_vel = self.get_velocity_range(channel) # Get pattern influence (0.0 to 1.0) pattern_value = self._calculate_pattern_value(channel, active_channel_count) pattern_value = pattern_value * self.pattern_intensity pattern_value = max(0.0, min(1.0, pattern_value)) # Blend base velocity with pattern pattern_velocity = min_vel + (max_vel - min_vel) * pattern_value # Combine with base velocity (weighted average) final_velocity = int((base_velocity + pattern_velocity) / 2) return max(0, min(127, final_velocity)) def _calculate_pattern_value(self, channel: int, active_channel_count: int) -> float: """Calculate raw pattern value (0.0 to 1.0) for a channel""" if self.current_pattern == "static": return 1.0 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 "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 build_cycle_length = self.bar_length * 2 # Build up and fade down over 2 bars build_progress = (self.pattern_position % build_cycle_length) / build_cycle_length * 2.0 if build_progress > 1.0: build_progress = 2.0 - build_progress # Fade back down return build_progress elif self.current_pattern == "fade": # Gradual diminuendo over bar length fade_cycle_progress = (self.pattern_position % self.bar_length) / self.bar_length fade_progress = 1.0 - fade_cycle_progress return fade_progress elif self.current_pattern == "pulse": # Sharp rhythmic pulses - 4 pulses per bar pulse_rate = (8 * math.pi) / self.bar_length # 4 complete cycles per bar pulse = math.sin(self.pattern_position * pulse_rate) return 1.0 if pulse > 0.8 else 0.3 elif self.current_pattern == "alternating": # Alternate between high and low based on bar subdivisions step_within_bar = int(self.pattern_position) % self.bar_length return 1.0 if step_within_bar % 2 == 0 else 0.3 elif self.current_pattern == "stutter": # Rapid volume changes stutter = math.sin(self.pattern_position * 8) * 0.5 + 0.5 return stutter elif self.current_pattern == "cascade": # Volume cascades across channels return self._cascade_pattern(channel, active_channel_count) elif self.current_pattern == "ripple": # Ripple effect from center return self._ripple_pattern(channel, active_channel_count) elif self.current_pattern == "spotlight": # Spotlight effect - one channel bright, others dim return self._spotlight_pattern(channel, active_channel_count) elif self.current_pattern == "bounce_volume": # Volume follows bounce pattern return self._bounce_volume_pattern(channel, active_channel_count) return 1.0 # Default fallback def _cascade_pattern(self, channel: int, active_channel_count: int) -> float: """Volume cascade across channels""" cascade_position = (self.pattern_position * 0.5) % active_channel_count distance = min( abs(channel - 1 - cascade_position), active_channel_count - abs(channel - 1 - cascade_position) ) return max(0.2, 1.0 - (distance / active_channel_count) * 0.8) def _ripple_pattern(self, channel: int, active_channel_count: int) -> float: """Ripple effect from center""" center = active_channel_count / 2 distance = abs(channel - 1 - center) ripple_phase = self.pattern_position - distance * 0.5 ripple = math.sin(ripple_phase) * 0.5 + 0.5 return max(0.2, ripple) def _spotlight_pattern(self, channel: int, active_channel_count: int) -> float: """Spotlight effect - one channel bright, others dim""" spotlight_channel = int(self.pattern_position * 0.3) % active_channel_count + 1 if channel == spotlight_channel: return 1.0 else: return 0.2 def _bounce_volume_pattern(self, channel: int, active_channel_count: int) -> float: """Volume follows bounce pattern between first and last channels""" bounce_position = (self.pattern_position * 0.8) % (2 * (active_channel_count - 1)) if bounce_position <= active_channel_count - 1: target_channel = bounce_position + 1 else: target_channel = 2 * active_channel_count - bounce_position - 1 distance = abs(channel - target_channel) return max(0.3, 1.0 - distance * 0.3) def get_all_channel_volumes(self, active_channel_count: int) -> Dict[int, float]: """Get volume values for all active channels""" volumes = {} for channel in range(1, active_channel_count + 1): volume = self.get_channel_volume(channel, active_channel_count) volumes[channel] = volume self.volume_updated.emit(channel, volume) return volumes def reset_pattern(self): """Reset pattern to beginning""" 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() def sync_with_arpeggiator_start(self): """Synchronize pattern start with arpeggiator start""" self.pattern_position = 0.0