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