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