ship-controller/animations.py
2025-06-30 02:32:11 +02:00

576 lines
19 KiB
Python

"""
Animation module for MIDI-to-Hue application.
Handles threaded animations for MIDI controller LEDs.
"""
import threading
import time
import mido
from typing import List, Dict, Any, Callable, Optional, Tuple, Union
class Animation:
"""Base class for animations that can be applied to different LED sets."""
def __init__(self, name: str):
"""
Initialize animation base class.
Args:
name: Name of the animation for identification
"""
self.name = name
self.running = False
self.thread = None
self.on_complete_callback = None
def start(self) -> None:
"""Start the animation in a separate thread."""
if self.thread and self.thread.is_alive():
return
self.running = True
self.thread = threading.Thread(target=self._run)
self.thread.daemon = True
self.thread.start()
def stop(self) -> None:
"""Stop the animation thread."""
self.running = False
if self.thread:
self.thread.join(1.0) # Wait up to 1 second for thread to finish
def _run(self) -> None:
"""Main animation loop. To be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement _run method")
def set_on_complete_callback(self, callback: Callable) -> None:
"""
Set a callback to be called when this animation completes.
Args:
callback: Function to call when animation completes
"""
self.on_complete_callback = callback
def notify_complete(self) -> None:
"""
Notify that this animation has completed by calling the callback.
"""
if self.on_complete_callback:
self.on_complete_callback()
class MidiLedAnimation(Animation):
"""Animation for MIDI controller LEDs."""
def __init__(
self,
name: str,
midi_controller,
led_sequence: List[Tuple[int, int]], # List of (channel, note) tuples
interval: float = 0.1,
pattern: str = "chase",
on_velocity: int = 127,
off_velocity: int = 0
):
"""
Initialize MIDI LED animation.
Args:
name: Animation name
midi_controller: MidiController instance
led_sequence: List of (channel, note) tuples defining the LEDs to animate
interval: Time between steps in seconds
pattern: Animation pattern name ("chase", "pulse", "alternate", etc.)
on_velocity: MIDI velocity for "on" state (0-127)
off_velocity: MIDI velocity for "off" state (0-127)
"""
super().__init__(name)
self.midi_controller = midi_controller
self.led_sequence = led_sequence
self.interval = interval
self.pattern = pattern
self.on_velocity = on_velocity
self.off_velocity = off_velocity
# Pattern functions mapped to names
self.pattern_functions = {
"chase": self._chase_pattern,
"reverse_chase": self._reverse_chase_pattern,
"pulse": self._pulse_pattern,
"alternate": self._alternate_pattern,
"random": self._random_pattern
}
# Animation parameters
self.repeat_count = None # None means infinite, otherwise number of repeats
self.current_repeat = 0
def _run(self) -> None:
"""Run the animation loop based on the selected pattern."""
if self.pattern in self.pattern_functions:
# Call the appropriate pattern function
self.pattern_functions[self.pattern]()
else:
# Default to chase pattern if unknown pattern specified
self._chase_pattern()
def _chase_pattern(self) -> None:
"""
Chase pattern: turn on each LED in sequence, turning off the previous one.
Like a "running light" effect going forward through the sequence.
"""
current_idx = 0
completed_repeats = 0
while self.running:
# Turn off all LEDs
for i, (channel, note) in enumerate(self.led_sequence):
if i != current_idx: # Skip the current position
self._send_midi_note(channel, note, self.off_velocity)
# Turn on current LED
channel, note = self.led_sequence[current_idx]
self._send_midi_note(channel, note, self.on_velocity)
# Wait interval
time.sleep(self.interval)
# Move to next position
current_idx = (current_idx + 1) % len(self.led_sequence)
# Check for completion of a full cycle
if current_idx == 0:
completed_repeats += 1
if self.repeat_count and completed_repeats >= self.repeat_count:
self.running = False
break
# Notify completion if callback is set
self.notify_complete()
def _reverse_chase_pattern(self) -> None:
"""
Reverse chase pattern: turn on each LED in reverse sequence.
Like a "running light" effect going backward through the sequence.
"""
current_idx = 0
completed_repeats = 0
while self.running:
# Turn off all LEDs
for i, (channel, note) in enumerate(self.led_sequence):
if i != current_idx: # Skip the current position
self._send_midi_note(channel, note, self.off_velocity)
# Turn on current LED
channel, note = self.led_sequence[current_idx]
self._send_midi_note(channel, note, self.on_velocity)
# Wait interval
time.sleep(self.interval)
# Move to previous position (reverse direction)
current_idx = (current_idx - 1) % len(self.led_sequence)
# Check for completion of a full cycle
if current_idx == len(self.led_sequence) - 1:
completed_repeats += 1
if self.repeat_count and completed_repeats >= self.repeat_count:
self.running = False
break
# Notify completion if callback is set
self.notify_complete()
def _pulse_pattern(self) -> None:
"""
Pulse pattern: all LEDs turn on and off together.
"""
state = True # Start with all on
while self.running:
velocity = self.on_velocity if state else self.off_velocity
# Set all LEDs to current state
for channel, note in self.led_sequence:
self._send_midi_note(channel, note, velocity)
# Wait interval
time.sleep(self.interval)
# Toggle state
state = not state
def _alternate_pattern(self) -> None:
"""
Alternate pattern: alternate between odd and even LEDs.
"""
state = True # True = evens on, odds off; False = odds on, evens off
while self.running:
for i, (channel, note) in enumerate(self.led_sequence):
is_even = i % 2 == 0
velocity = self.on_velocity if is_even == state else self.off_velocity
self._send_midi_note(channel, note, velocity)
# Wait interval
time.sleep(self.interval)
# Toggle state
state = not state
def _random_pattern(self) -> None:
"""
Random pattern: randomized blinking of LEDs.
"""
import random
while self.running:
# Randomly select LEDs to turn on
for channel, note in self.led_sequence:
velocity = self.on_velocity if random.random() > 0.5 else self.off_velocity
self._send_midi_note(channel, note, velocity)
# Wait interval
time.sleep(self.interval)
def _send_midi_note(self, channel: int, note: int, velocity: int) -> None:
"""
Send MIDI note message to control an LED.
Args:
channel: MIDI channel
note: Note number
velocity: Note velocity (0-127)
"""
try:
msg = mido.Message('note_on', note=note, velocity=velocity, channel=channel)
self.midi_controller.send_message(msg)
except Exception as e:
print(f"Error sending MIDI message: {e}")
class AnimationSequence(Animation):
"""
Animation that consists of a sequence of other animations.
Runs animations one after another in sequence.
"""
def __init__(self, name: str, animations: List[Animation]):
"""
Initialize animation sequence.
Args:
name: Name of the sequence
animations: List of animation instances to run in sequence
"""
super().__init__(name)
self.animations = animations
self.current_index = 0
# Set up callbacks for chaining animations
for i in range(len(animations) - 1):
animations[i].set_on_complete_callback(self._next_animation)
def _run(self) -> None:
"""
Run animations in sequence.
"""
if not self.animations:
return
# Start first animation
self.current_index = 0
self.animations[0].start()
# Wait for all animations to complete
while self.running and self.current_index < len(self.animations):
# Just wait - callbacks handle progression
time.sleep(0.1)
# Check if current animation is still running
if self.animations[self.current_index].thread and not self.animations[self.current_index].thread.is_alive():
# If for some reason the callback wasn't triggered, move to next
self._next_animation()
# Notify completion
self.notify_complete()
def _next_animation(self, *args) -> None:
"""
Start the next animation in sequence.
Called when current animation completes.
"""
if not self.running:
return
# Move to next animation
self.current_index += 1
# If we have more animations, start the next one
if self.current_index < len(self.animations):
self.animations[self.current_index].start()
def stop(self) -> None:
"""
Stop the animation sequence and all contained animations.
"""
super().stop()
for animation in self.animations:
animation.stop()
class AnimationCycle(Animation):
"""
Repeatedly cycles through a sequence of animations.
"""
def __init__(self, name: str, animations: List[Animation], repeat_count: int = None):
"""
Initialize animation cycle.
Args:
name: Name of the cycle
animations: List of animation instances to cycle through
repeat_count: Number of times to repeat the entire cycle (None = infinite)
"""
super().__init__(name)
self.animations = animations
self.repeat_count = repeat_count
self.current_animation_index = 0
self.cycle_count = 0
# Set up callbacks for chaining animations
for animation in animations:
animation.set_on_complete_callback(self._next_animation)
def _run(self) -> None:
"""
Run the animation cycle.
"""
if not self.animations:
return
# Start first animation
self.current_animation_index = 0
self.cycle_count = 0
self.animations[0].start()
# Wait for cycle to complete
while self.running and (self.repeat_count is None or self.cycle_count < self.repeat_count):
time.sleep(0.1)
def _next_animation(self, *args) -> None:
"""
Start the next animation in the cycle.
"""
if not self.running:
return
# Move to next animation
self.current_animation_index = (self.current_animation_index + 1) % len(self.animations)
# Check if we've completed a full cycle
if self.current_animation_index == 0:
self.cycle_count += 1
if self.repeat_count and self.cycle_count >= self.repeat_count:
self.running = False
self.notify_complete()
return
# Start next animation
self.animations[self.current_animation_index].start()
def stop(self) -> None:
"""
Stop the animation cycle and all contained animations.
"""
super().stop()
for animation in self.animations:
animation.stop()
class AnimationManager:
"""Manages multiple animations."""
def __init__(self):
"""Initialize the animation manager."""
self.animations = {}
def add_animation(self, animation: Animation) -> None:
"""
Add an animation to the manager.
Args:
animation: Animation instance
"""
self.animations[animation.name] = animation
def start_animation(self, name: str) -> bool:
"""
Start a specific animation.
Args:
name: Name of the animation to start
Returns:
True if animation was found and started, False otherwise
"""
if name in self.animations:
self.animations[name].start()
return True
return False
def stop_animation(self, name: str) -> bool:
"""
Stop a specific animation.
Args:
name: Name of the animation to stop
Returns:
True if animation was found and stopped, False otherwise
"""
if name in self.animations:
self.animations[name].stop()
return True
return False
def stop_all(self) -> None:
"""Stop all running animations."""
for animation in self.animations.values():
animation.stop()
def create_deck_animation(
self,
name: str,
midi_controller,
deck_side: str,
pattern: str = "chase",
interval: float = 0.1,
on_velocity: int = 127,
off_velocity: int = 0,
repeat_count: int = None
) -> MidiLedAnimation:
"""
Create a deck animation with the appropriate LED sequence.
Args:
name: Animation name
midi_controller: MidiController instance
deck_side: Either "left" or "right"
pattern: Animation pattern
interval: Time between steps
on_velocity: Velocity for "on" state
off_velocity: Velocity for "off" state
Returns:
Created MidiLedAnimation instance
"""
# Define deck LED sequences - channel/note pairs for each deck
deck_sequences = {
"left": [
(1, 3), (1, 4), (1, 5), (1, 6),
(1, 10), (1, 9), (1, 8), (1, 7)
],
"right": [
(3, 3), (3, 4), (3, 5), (3, 6),
(3, 10), (3, 9), (3, 8), (3, 7)
]
}
if deck_side not in deck_sequences:
raise ValueError(f"Unknown deck side: {deck_side}. Use 'left' or 'right'.")
animation = MidiLedAnimation(
name=name,
midi_controller=midi_controller,
led_sequence=deck_sequences[deck_side],
pattern=pattern,
interval=interval,
on_velocity=on_velocity,
off_velocity=off_velocity
)
# Set repeat count if specified
animation.repeat_count = repeat_count
# Auto-add to manager
self.add_animation(animation)
return animation
def create_alternating_chase_animation(
self,
name: str,
midi_controller,
deck_side: str,
interval: float = 0.1,
cycles_per_direction: int = 1,
on_velocity: int = 127,
off_velocity: int = 0
) -> AnimationCycle:
"""
Create an animation that alternates between chase and reverse chase patterns.
Args:
name: Animation name
midi_controller: MidiController instance
deck_side: Either "left" or "right"
interval: Time between steps in seconds
cycles_per_direction: Number of complete cycles in each direction
on_velocity: Velocity for "on" state
off_velocity: Velocity for "off" state
Returns:
AnimationCycle instance
"""
# Define deck LED sequences - channel/note pairs for each deck
deck_sequences = {
"left": [
(1, 3), (1, 4), (1, 5), (1, 6),
(1, 10), (1, 9), (1, 8), (1, 7)
],
"right": [
(3, 3), (3, 4), (3, 5), (3, 6),
(3, 10), (3, 9), (3, 8), (3, 7)
]
}
if deck_side not in deck_sequences:
raise ValueError(f"Unknown deck side: {deck_side}. Use 'left' or 'right'.")
# Create forward chaser animation
forward_animation = MidiLedAnimation(
name=f"{name}_forward",
midi_controller=midi_controller,
led_sequence=deck_sequences[deck_side],
pattern="chase",
interval=interval,
on_velocity=on_velocity,
off_velocity=off_velocity
)
forward_animation.repeat_count = cycles_per_direction
# Create reverse chaser animation
reverse_animation = MidiLedAnimation(
name=f"{name}_reverse",
midi_controller=midi_controller,
led_sequence=deck_sequences[deck_side],
pattern="reverse_chase",
interval=interval,
on_velocity=on_velocity,
off_velocity=off_velocity
)
reverse_animation.repeat_count = cycles_per_direction
# Create cycle that alternates between forward and reverse
cycle = AnimationCycle(
name=name,
animations=[forward_animation, reverse_animation]
)
# Add all animations to manager
self.add_animation(cycle)
return cycle