diff --git a/src/open_duck_mini_runtime/assets/beep1.wav b/src/assets/beep1.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/beep1.wav rename to src/assets/beep1.wav diff --git a/src/open_duck_mini_runtime/assets/beep2.wav b/src/assets/beep2.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/beep2.wav rename to src/assets/beep2.wav diff --git a/src/open_duck_mini_runtime/assets/happy1.wav b/src/assets/happy1.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/happy1.wav rename to src/assets/happy1.wav diff --git a/src/open_duck_mini_runtime/assets/happy2.wav b/src/assets/happy2.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/happy2.wav rename to src/assets/happy2.wav diff --git a/src/open_duck_mini_runtime/assets/happy3.wav b/src/assets/happy3.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/happy3.wav rename to src/assets/happy3.wav diff --git a/src/open_duck_mini_runtime/assets/lamp.wav b/src/assets/lamp.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/lamp.wav rename to src/assets/lamp.wav diff --git a/src/open_duck_mini_runtime/assets/lamp2.wav b/src/assets/lamp2.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/lamp2.wav rename to src/assets/lamp2.wav diff --git a/src/open_duck_mini_runtime/assets/lamp3.wav b/src/assets/lamp3.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/lamp3.wav rename to src/assets/lamp3.wav diff --git a/src/open_duck_mini_runtime/assets/motor.wav b/src/assets/motor.wav similarity index 100% rename from src/open_duck_mini_runtime/assets/motor.wav rename to src/assets/motor.wav diff --git a/src/open_duck_mini_runtime/assets/polynomial_coefficients.pkl b/src/assets/polynomial_coefficients.pkl similarity index 100% rename from src/open_duck_mini_runtime/assets/polynomial_coefficients.pkl rename to src/assets/polynomial_coefficients.pkl diff --git a/src/open_duck_mini_runtime/led_controller.py b/src/open_duck_mini_runtime/led_controller.py index 5fd2851..6d27a0a 100644 --- a/src/open_duck_mini_runtime/led_controller.py +++ b/src/open_duck_mini_runtime/led_controller.py @@ -9,7 +9,7 @@ Design: from __future__ import annotations from threading import Lock -from typing import Tuple, Optional +from typing import Tuple, Optional, Union, Dict # Direct hardware imports (we assume we're running on-device) import board @@ -38,42 +38,85 @@ class LedController: # Use the module-level NeoPixel configured above self._pixels = pixels - # Cache simple color tuples - self.OFF = (0, 0, 0) - self.WHITE = (255, 255, 255) + # Cache simple color tuples (use 4-tuple for RGBW strips) + # Colors are stored in logical (R, G, B, W) regardless of ORDER. + self.OFF = (0, 0, 0, 0) + self.WHITE = (0, 0, 0, 255) + self.RED = (255, 0, 0, 0) + self.GREEN = (0, 255, 0, 0) + self.BLUE = (0, 0, 255, 0) + + self._named_colors = { + "off": self.OFF, + "white": self.WHITE, + "red": self.RED, + "green": self.GREEN, + "blue": self.BLUE, + } # Track on/off states (for toggling and consistent show) self.eye_left_on = True self.eye_right_on = True self.projector_on = False + # Track current colors for each pixel (default WHITE) + self.left_color = self.WHITE + self.right_color = self.WHITE + self.proj_color = self.WHITE + # Ensure an initial known state self._apply() # Internal helper to apply current states to pixels def _apply( self, - left_color: Optional[Tuple[int, int, int]] = None, - right_color: Optional[Tuple[int, int, int]] = None, - proj_color: Optional[Tuple[int, int, int]] = None, + left_color: Optional[Tuple[int, int, int, int]] = None, + right_color: Optional[Tuple[int, int, int, int]] = None, + proj_color: Optional[Tuple[int, int, int, int]] = None, ) -> None: if self._pixels is None or self._deinited: return with self._lock: # Determine colors from boolean states when not explicitly specified if left_color is None: - left_color = self.WHITE if self.eye_left_on else self.OFF + left_color = self.left_color if self.eye_left_on else self.OFF if right_color is None: - right_color = self.WHITE if self.eye_right_on else self.OFF + right_color = self.right_color if self.eye_right_on else self.OFF if proj_color is None: - proj_color = self.WHITE if self.projector_on else self.OFF + proj_color = self.proj_color if self.projector_on else self.OFF # Assign indices: 2-left, 1-right, 0-projector - self._pixels[0] = proj_color - self._pixels[1] = right_color - self._pixels[2] = left_color + self._pixels[0] = self._to_order(proj_color) + self._pixels[1] = self._to_order(right_color) + self._pixels[2] = self._to_order(left_color) self._pixels.show() + def _to_order(self, color_rgba: Tuple[int, int, int, int]): + """ + Convert logical (R,G,B,W) into the configured NeoPixel ORDER tuple. + For RGB strips (no W), we will drop the W channel. + """ + r, g, b, w = color_rgba + try: + # neopixel library generally accepts (r,g,b) or (r,g,b,w) depending on pixel type + # We map to the declared ORDER for clarity when direct indexing is used. + if ORDER in (neopixel.RGB, neopixel.GRB): + mapping = { + neopixel.RGB: (r, g, b), + neopixel.GRB: (g, r, b), + } + return mapping[ORDER] + else: + # RGBW variants + mapping = { + neopixel.RGBW: (r, g, b, w), + neopixel.GRBW: (g, r, b, w), + } + return mapping.get(ORDER, (r, g, b, w)) + except Exception: + # Fallback: return RGBA or RGB as-is + return (r, g, b, w) + # Eyes API def set_eyes(self, on: bool) -> None: self.eye_left_on = on @@ -93,6 +136,47 @@ class LedController: self.projector_on = on self._apply() + # Color API (accepts name or tuple) + def _norm_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]] + ) -> Tuple[int, int, int, int]: + if isinstance(color, str): + c = self._named_colors.get(color.lower()) + if c is None: + raise ValueError(f"Unknown color name: {color}") + return c + # If 3-tuple provided, assume RGB on RGBW strip -> map to (r,g,b,0) + if isinstance(color, tuple) and len(color) == 3: + r, g, b = color + return (r, g, b, 0) + if isinstance(color, tuple) and len(color) == 4: + return color # already RGBA(W) + raise ValueError("Color must be a name or RGB/RGBW tuple") + + def set_left_eye_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]]): + self.left_color = self._norm_color(color) + self._apply() + + def set_right_eye_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]]): + self.right_color = self._norm_color(color) + self._apply() + + def set_projector_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]]): + self.proj_color = self._norm_color(color) + self._apply() + + def set_eyes_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]]): + norm = self._norm_color(color) + self.left_color = norm + self.right_color = norm + self._apply() + + def set_all_color(self, color: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]]): + norm = self._norm_color(color) + self.left_color = norm + self.right_color = norm + self.proj_color = norm + self._apply() + # Utilities def all_off(self) -> None: self.eye_left_on = False diff --git a/src/open_duck_mini_runtime/sounds.py b/src/open_duck_mini_runtime/sounds.py index 7cc1e3a..ad22c4f 100644 --- a/src/open_duck_mini_runtime/sounds.py +++ b/src/open_duck_mini_runtime/sounds.py @@ -2,14 +2,22 @@ import pygame import time import os import random +from threading import Thread, Lock + +from open_duck_mini_runtime.led_controller import get_controller class Sounds: - def __init__(self, volume=1.0, sound_directory="./"): + def __init__(self, volume=1.0, sound_directory="", default_color: str = "white", sound_color_map: dict | None = None): pygame.mixer.init() pygame.mixer.music.set_volume(volume) self.sounds = {} self.ok = True + self._ctrl = get_controller() + self._default_color = default_color + self._sound_color_map = sound_color_map or {} + self._color_token = 0 + self._lock = Lock() try: for file in os.listdir(sound_directory): if file.endswith(".wav"): @@ -26,13 +34,64 @@ class Sounds: print("No sound files found in the directory.") self.ok = False + # Initialize color map defaults (cycle through a limited palette) + if self.ok and not self._sound_color_map: + palette = ["red", "green", "blue", "white"] + for idx, name in enumerate(sorted(self.sounds.keys())): + self._sound_color_map[name] = palette[idx % len(palette)] + + # Ensure LEDs are default color initially (without changing on/off state) + try: + self._ctrl.set_all_color(self._default_color) + except Exception: + pass + def play(self, sound_name): if not self.ok: print("Sounds not initialized properly.") return if sound_name in self.sounds: - self.sounds[sound_name].play() + chan = self.sounds[sound_name].play() print(f"Playing: {sound_name}") + + # Change LEDs to mapped color (supports string for all or dict per pixel) + mapping_val = self._sound_color_map.get(sound_name, self._default_color) + try: + if isinstance(mapping_val, dict): + left = mapping_val.get("left", self._default_color) + right = mapping_val.get("right", self._default_color) + proj = mapping_val.get("projector", self._default_color) + self._ctrl.set_left_eye_color(left) + self._ctrl.set_right_eye_color(right) + self._ctrl.set_projector_color(proj) + else: + self._ctrl.set_all_color(mapping_val) + except Exception as e: + print(f"LED color set failed: {e}") + + # Schedule reset to default when the sound finishes + with self._lock: + self._color_token += 1 + token = self._color_token + + def _reset_when_done(channel, expected_token): + try: + # If no channel returned, fallback to sleep by length + if channel is None: + duration = self.sounds[sound_name].get_length() + time.sleep(duration) + else: + # Wait until the channel is no longer busy + while channel.get_busy(): + time.sleep(0.02) + # Only reset if no newer sound changed the color + with self._lock: + if expected_token == self._color_token: + self._ctrl.set_all_color(self._default_color) + except Exception: + pass + + Thread(target=_reset_when_done, args=(chan, token), daemon=True).start() else: print(f"Sound '{sound_name}' not found!") @@ -41,10 +100,24 @@ class Sounds: print("Sounds not initialized properly.") return sound_name = random.choice(list(self.sounds.keys())) - self.sounds[sound_name].play() + self.play(sound_name) def play_happy(self): - self.sounds["happy1.wav"].play() + self.play("happy1.wav") + + # API helpers + def set_default_color(self, color: str): + self._default_color = color + try: + self._ctrl.set_all_color(self._default_color) + except Exception: + pass + + def set_sound_color(self, sound_name: str, color: str): + self._sound_color_map[sound_name] = color + + def set_sound_color_map(self, mapping: dict): + self._sound_color_map.update(mapping) @@ -53,6 +126,5 @@ if __name__ == "__main__": sound_player = Sounds(1.0, "../assets/") time.sleep(1) while True: - # sound_player.play_random_sound() - sound_player.play_happy() - time.sleep(3) + sound_player.play_random_sound() + time.sleep(5)