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.
 

305 lines
13 KiB

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