diff --git a/music_sync/hear_my_hope.py b/music_sync/hear_my_hope.py new file mode 100644 index 0000000..8d01f5c --- /dev/null +++ b/music_sync/hear_my_hope.py @@ -0,0 +1,175 @@ +""" +hear_my_hope.py - Music-synced lighting show for "Hear My Hope" (Hazbin Hotel). + +Mode A (manual sync): + python music_sync/hear_my_hope.py + → Press Enter when the song starts playing. Cues fire on a timer. + +Mode B (audio file sync): + python music_sync/hear_my_hope.py --audio "Hear My Hope.mp3" + → Plays the song via pygame and fires cues automatically. + +Cues are defined in music_sync/cues.json — edit timestamps there. +""" + +import asyncio +import argparse +import json +import sys +import time +import os + +# Allow running from project root or music_sync/ +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from controller.kaiyu_controller import KaiyuController +from controller.config import COLORS + + +def load_cues(path: str = None) -> list[dict]: + if path is None: + path = os.path.join(os.path.dirname(__file__), "cues.json") + with open(path) as f: + data = json.load(f) + cues = sorted(data["cues"], key=lambda c: c["t"]) + return cues + + +async def run_cues(ctrl: KaiyuController, cues: list[dict], start_time: float): + """Fire cues based on elapsed time since start_time.""" + cue_index = 0 + total = len(cues) + + print(f"\nShow started. {total} cues loaded.\n") + + while cue_index < total: + now = time.monotonic() + elapsed = now - start_time + cue = cues[cue_index] + + if elapsed >= cue["t"]: + await fire_cue(ctrl, cue) + cue_index += 1 + else: + # Sleep until the next cue (or check every 50ms) + wait = min(cue["t"] - elapsed, 0.05) + await asyncio.sleep(max(0, wait)) + + print("\nAll cues complete.") + + +async def fire_cue(ctrl: KaiyuController, cue: dict): + action = cue["action"] + note = cue.get("note", "") + t = cue["t"] + color_name = cue.get("color", "off") + rgb = COLORS.get(color_name, (0, 0, 0)) + + print(f" [{t:>7.1f}s] {action:<8} {color_name:<16} # {note}") + + if action == "off": + await ctrl.off() + + elif action == "set": + await ctrl.set_color(*rgb) + + elif action == "fade": + duration = cue.get("duration", 1.0) + # Fire as background task so timing isn't blocked + asyncio.create_task(ctrl.fade_to(*rgb, duration=duration)) + + elif action == "flash": + times = cue.get("times", 3) + on_time = cue.get("on_time", 0.2) + off_time = cue.get("off_time", 0.1) + asyncio.create_task(ctrl.flash(*rgb, times=times, on_time=on_time, off_time=off_time)) + + elif action == "pulse": + cycles = cue.get("cycles", 2) + period = cue.get("period", 1.0) + asyncio.create_task(ctrl.pulse(*rgb, cycles=cycles, period=period)) + + else: + print(f" Unknown action: {action}") + + +async def manual_sync(ctrl: KaiyuController, cues: list[dict]): + """Wait for user to press Enter, then start the cue timer.""" + print("=" * 60) + print(" HAZBIN HOTEL - HEAR MY HOPE | Manual Sync Mode") + print("=" * 60) + print("\nStart playing 'Hear My Hope' and press Enter to begin lighting sync.") + input() + start_time = time.monotonic() + await run_cues(ctrl, cues, start_time) + + +async def audio_sync(ctrl: KaiyuController, cues: list[dict], audio_path: str): + """Play audio file via pygame and sync cues to playback.""" + try: + import pygame + except ImportError: + print("pygame not installed. Run: pip install pygame") + sys.exit(1) + + pygame.mixer.init() + pygame.mixer.music.load(audio_path) + + print("=" * 60) + print(" HAZBIN HOTEL - HEAR MY HOPE | Audio Sync Mode") + print("=" * 60) + print(f"\nAudio: {audio_path}") + input("Press Enter to start...") + + pygame.mixer.music.play() + start_time = time.monotonic() + await run_cues(ctrl, cues, start_time) + + pygame.mixer.music.stop() + pygame.mixer.quit() + + +async def preview_cues(cues: list[dict]): + """Print the cue list without connecting to hardware.""" + print("\n{'='*60}") + print(" CUE LIST PREVIEW - Hear My Hope") + print("=" * 60) + print(f" {'Time':>8} {'Action':<10} {'Color':<18} Note") + print(f" {'-'*8} {'-'*10} {'-'*18} {'-'*30}") + for cue in cues: + t = cue["t"] + action = cue.get("action", "?") + color = cue.get("color", "-") + note = cue.get("note", "") + print(f" {t:>8.1f}s {action:<10} {color:<18} {note}") + print() + + +async def main(): + parser = argparse.ArgumentParser(description="Hear My Hope - LED Lighting Show") + parser.add_argument("--audio", "-a", help="Path to audio file (mp3/wav) for auto-sync") + parser.add_argument("--preview", action="store_true", help="Preview cue list only (no BLE)") + parser.add_argument("--cues", default=None, help="Path to custom cues.json") + args = parser.parse_args() + + cues = load_cues(args.cues) + + if args.preview: + await preview_cues(cues) + return + + async with KaiyuController() as ctrl: + await ctrl.on() + await asyncio.sleep(0.5) + + if args.audio: + await audio_sync(ctrl, cues, args.audio) + else: + await manual_sync(ctrl, cues) + + await asyncio.sleep(1.0) + await ctrl.off() + + +if __name__ == "__main__": + asyncio.run(main())