Add music sync controller for Hear My Hope
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user