feat: add animations utils and right deck chaser as first animation

This commit is contained in:
Jan Häusler 2025-06-30 02:17:13 +02:00
parent 4cfd750afc
commit 5b48ee7f36
2 changed files with 332 additions and 2 deletions

290
animations.py Normal file
View file

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

44
main.py
View file

@ -1,19 +1,25 @@
""" """
Main module for MIDI-to-Hue application. 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 sys
import time import time
import threading
import signal
from config import ConfigManager from config import ConfigManager
from hue_controller import HueController from hue_controller import HueController
from midi_controller import MidiController, DeviceMappingManager from midi_controller import MidiController, DeviceMappingManager
from mapper import MidiToHueMapper from mapper import MidiToHueMapper
from animations import AnimationManager, MidiLedAnimation
def main(): def main():
"""Main application entry point.""" """Main application entry point."""
# Load configuration # Load configuration
config_manager = ConfigManager() config_manager = ConfigManager()
# Create animation manager
animation_manager = AnimationManager()
# Initialize Hue controller # Initialize Hue controller
try: try:
hue_controller = HueController( hue_controller = HueController(
@ -40,6 +46,14 @@ def main():
"note_on/1/8": 'left_deck_6', "note_on/1/8": 'left_deck_6',
"note_on/1/9": 'left_deck_7', "note_on/1/9": 'left_deck_7',
"note_on/1/10": 'left_deck_8', "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/5/1": 'left_volume_slider',
"control_change/6/1": 'right_volume_slider', "control_change/6/1": 'right_volume_slider',
} }
@ -57,16 +71,42 @@ def main():
config_manager.get_mappings() 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(): if not midi_controller.open():
print("Failed to open MIDI port.") print("Failed to open MIDI port.")
return 1 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: try:
# Process MIDI messages in main thread
midi_controller.process_messages() midi_controller.process_messages()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nExiting program...") print("\nExiting program...")
finally: finally:
animation_manager.stop_all()
midi_controller.close() midi_controller.close()
return 0 return 0