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