Add music sync controller for Hear My Hope

This commit is contained in:
2026-07-01 19:55:22 +10:00
parent c93c213a83
commit 53e9bd4773
+175
View File
@@ -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())