From 5b48ee7f3623bccb8929c7f6b197ee55dcb61b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=A4usler?= Date: Mon, 30 Jun 2025 02:17:13 +0200 Subject: [PATCH] feat: add animations utils and right deck chaser as first animation --- animations.py | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 44 +++++++- 2 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 animations.py diff --git a/animations.py b/animations.py new file mode 100644 index 0000000..5ea8f98 --- /dev/null +++ b/animations.py @@ -0,0 +1,290 @@ +""" +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 + + 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") + + +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, + "pulse": self._pulse_pattern, + "alternate": self._alternate_pattern, + "random": self._random_pattern + } + + 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. + """ + current_idx = 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) + + 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 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 + ) -> 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 + ) + + # Auto-add to manager + self.add_animation(animation) + return animation diff --git a/main.py b/main.py index d294427..7401c06 100644 --- a/main.py +++ b/main.py @@ -1,19 +1,25 @@ """ Main module for MIDI-to-Hue application. -Ties together the config, MIDI controller, Hue controller, and mapper. +Ties together the config, MIDI controller, Hue controller, mapper, and animations. """ import sys import time +import threading +import signal from config import ConfigManager from hue_controller import HueController from midi_controller import MidiController, DeviceMappingManager from mapper import MidiToHueMapper +from animations import AnimationManager, MidiLedAnimation def main(): """Main application entry point.""" # Load configuration config_manager = ConfigManager() + # Create animation manager + animation_manager = AnimationManager() + # Initialize Hue controller try: hue_controller = HueController( @@ -40,6 +46,14 @@ def main(): "note_on/1/8": 'left_deck_6', "note_on/1/9": 'left_deck_7', "note_on/1/10": 'left_deck_8', + "note_on/3/3": 'right_deck_1', + "note_on/3/4": 'right_deck_2', + "note_on/3/5": 'right_deck_3', + "note_on/3/6": 'right_deck_4', + "note_on/3/7": 'right_deck_5', + "note_on/3/8": 'right_deck_6', + "note_on/3/9": 'right_deck_7', + "note_on/3/10": 'right_deck_8', "control_change/5/1": 'left_volume_slider', "control_change/6/1": 'right_volume_slider', } @@ -57,16 +71,42 @@ def main(): config_manager.get_mappings() ) - # Open MIDI port and process messages + # Setup right deck animation + print("Setting up right deck animation...") + right_deck_animation = animation_manager.create_deck_animation( + name="right_deck_chase", + midi_controller=midi_controller, + deck_side="right", + pattern="chase", + interval=0.15 # Animation speed in seconds between steps + ) + + # Open MIDI port if not midi_controller.open(): print("Failed to open MIDI port.") return 1 + + # Start the deck animation + print("Starting right deck animation...") + animation_manager.start_animation("right_deck_chase") + + # Handle graceful shutdown + def signal_handler(sig, frame): + print("\nStopping animations and exiting...") + animation_manager.stop_all() + midi_controller.close() + sys.exit(0) + + # Register signal handler for Ctrl+C + signal.signal(signal.SIGINT, signal_handler) try: + # Process MIDI messages in main thread midi_controller.process_messages() except KeyboardInterrupt: print("\nExiting program...") finally: + animation_manager.stop_all() midi_controller.close() return 0