feat(02-06): wire all Phase 2 modules into asyncio REPL entry point

- Create src/led_sync/main.py with AppState, handle_command, repl, async_main, main
- Implements all D-13 REPL commands: play/pause/resume/seek/stop/load/add/save/status/beat/help/quit
- Create src/led_sync/__main__.py for python -m led_sync invocation
- Add led-sync script entry point to pyproject.toml
- Wires AudioPlayer, ChoreographyScheduler, BeatDetector, ESP32Transport
This commit is contained in:
Claude
2026-04-03 14:54:38 +02:00
parent d04990f6c5
commit e544d57552
3 changed files with 257 additions and 0 deletions

View File

@@ -15,6 +15,9 @@ dependencies = [
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.scripts]
led-sync = "led_sync.main:main"
[tool.hatch.build.targets.wheel]
packages = ["src/led_sync"]

3
src/led_sync/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from led_sync.main import main
main()

251
src/led_sync/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""
LED Sync Studio — CLI entry point (Phase 2).
Wires all modules: audio playback, beat detection, choreography scheduling, UDP transport.
Usage:
uv run python -m led_sync <esp32_ip> [--port 4210] [--beat-device <device_name>]
Phase 3 Textual TUI will replace this REPL while reusing the same module instances.
Design (D-13/D-14): minimal error handling, asyncio REPL, not a production interface.
"""
import argparse
import asyncio
import json
import logging
import sys
from led_sync.models import ChoreoEvent, ChoreoFile
from led_sync.transport import create_esp32_transport
from led_sync.audio import AudioPlayer, BeatDetector
from led_sync.scheduler import ChoreographyScheduler
logging.basicConfig(
level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s",
)
class AppState:
"""Holds all runtime state — designed for Phase 3 TUI integration."""
def __init__(self):
self.transport = None
self.player = AudioPlayer()
self.scheduler: ChoreographyScheduler | None = None
self.detector: BeatDetector | None = None
self.choreo: ChoreoFile | None = None
self.current_file: str | None = None
async def handle_command(line: str, state: AppState) -> bool:
"""Handle one REPL command. Returns False to exit."""
parts = line.strip().split()
if not parts:
return True
cmd = parts[0].lower()
if cmd == "quit":
return False
elif cmd == "play":
if len(parts) < 2:
print("Usage: play <audio_file>")
return True
file_path = parts[1]
state.current_file = file_path
state.player.play(file_path)
if state.choreo and state.scheduler:
state.scheduler.play(state.choreo.events)
print(f"Playing: {file_path}")
elif cmd == "pause":
state.player.pause()
if state.scheduler:
state.scheduler.pause()
print(f"Paused at {state.player.position_seconds:.2f}s")
elif cmd == "resume":
state.player.resume()
if state.scheduler:
state.scheduler.resume()
print("Resumed")
elif cmd == "seek":
if len(parts) < 2:
print("Usage: seek <seconds>")
return True
try:
secs = float(parts[1])
except ValueError:
print("seek: invalid number")
return True
state.player.seek(secs)
if state.scheduler and state.choreo:
state.scheduler.play(state.choreo.events, seek_seconds=secs)
print(f"Seeked to {secs:.2f}s")
elif cmd == "stop":
state.player.stop()
if state.scheduler:
state.scheduler.stop()
print("Stopped")
elif cmd == "load":
if len(parts) < 2:
print("Usage: load <choreo.json>")
return True
path = parts[1]
try:
state.choreo = ChoreoFile.load(path)
print(f"Loaded {path}: {len(state.choreo.events)} events, song={state.choreo.song_path}")
except Exception as e:
print(f"Error loading {path}: {e}")
elif cmd == "add":
# add <timestamp> <zone> <animation> [params_json]
if len(parts) < 4:
print("Usage: add <timestamp> <zone> <animation> [params_json]")
return True
try:
ts = float(parts[1])
zone = parts[2]
animation = parts[3]
params = json.loads(parts[4]) if len(parts) > 4 else {}
except (ValueError, json.JSONDecodeError) as e:
print(f"add: parse error: {e}")
return True
if state.choreo is None:
state.choreo = ChoreoFile(song_path=state.current_file or "")
try:
event = ChoreoEvent(timestamp=ts, zone=zone, animation=animation, params=params)
state.choreo.add_event(event)
print(f"Added: t={ts:.2f}s zone={zone} animation={animation}")
except Exception as e:
print(f"add: validation error: {e}")
elif cmd == "save":
if len(parts) < 2:
print("Usage: save <file>")
return True
if state.choreo is None:
print("No choreography loaded. Use 'load' first or 'add' some events.")
return True
path = parts[1]
try:
state.choreo.save(path)
print(f"Saved to {path}")
except Exception as e:
print(f"save error: {e}")
elif cmd == "status":
esp = state.transport
connected = esp.connected if esp else False
last_status = esp.last_status if esp else None
pos = state.player.position_seconds
playing = state.player.is_playing
beat_count = state.detector.beat_count if state.detector else 0
bpm = state.detector.bpm if state.detector else 0.0
events = len(state.choreo.events) if state.choreo else 0
print(
f"ESP32: {'connected' if connected else 'disconnected'} | "
f"Status: {last_status}\n"
f"Player: {'playing' if playing else 'stopped/paused'} @ {pos:.2f}s\n"
f"Beats: {beat_count} | BPM: {bpm:.1f}\n"
f"Choreo: {events} events loaded"
)
elif cmd == "beat":
sub = parts[1].lower() if len(parts) > 1 else ""
device = parts[2] if len(parts) > 2 else None
if sub == "on":
if state.detector is None:
loop = asyncio.get_running_loop()
async def on_beat(beat_time: float):
print(f"[BEAT] t={beat_time:.3f}s BPM={state.detector.bpm:.1f}")
state.detector = BeatDetector(loop=loop, on_beat=on_beat)
try:
state.detector.start(device=device)
print(f"Beat detection started (device={device or 'default'})")
except RuntimeError as e:
print(f"Beat detection failed: {e}")
elif sub == "off":
if state.detector:
state.detector.stop()
print("Beat detection stopped")
else:
print("Usage: beat on [device_name] | beat off")
elif cmd == "help":
print(
"Commands:\n"
" play <file> — play audio file (+ choreo if loaded)\n"
" pause — pause playback\n"
" resume — resume playback\n"
" seek <seconds> — seek to position\n"
" stop — stop playback\n"
" load <choreo.json> — load choreography file\n"
" add <ts> <zone> <anim> [params_json] — add choreo event\n"
" save <file> — save choreography\n"
" beat on [device] — start beat detection\n"
" beat off — stop beat detection\n"
" status — show ESP32 state, position, beats\n"
" quit — exit"
)
else:
print(f"Unknown command: {cmd!r}. Type 'help' for commands.")
return True
async def repl(state: AppState) -> None:
"""Asyncio REPL loop (D-13). Uses run_in_executor for non-blocking input."""
loop = asyncio.get_running_loop()
print("LED Sync Studio CLI — type 'help' for commands, 'quit' to exit")
while True:
try:
line = await loop.run_in_executor(None, input, "> ")
except (EOFError, KeyboardInterrupt):
break
if not await handle_command(line, state):
break
async def async_main(esp32_host: str, esp32_port: int, beat_device: str | None) -> None:
state = AppState()
# Connect transport (D-10/D-11/D-12)
print(f"Connecting to ESP32 at {esp32_host}:{esp32_port}...")
state.transport = await create_esp32_transport(esp32_host, esp32_port)
state.scheduler = ChoreographyScheduler(transport=state.transport)
print(
f"Transport ready. ESP32 "
f"{'connected' if state.transport.connected else 'not yet responding (will retry on status ping)'}"
)
try:
await repl(state)
finally:
# Cleanup
state.player.stop()
if state.scheduler:
state.scheduler.stop()
if state.detector:
state.detector.stop()
print("Goodbye.")
def main() -> None:
parser = argparse.ArgumentParser(description="LED Sync Studio — Phase 2 CLI")
parser.add_argument("esp32_host", help="ESP32 IP address (e.g. 192.168.1.50)")
parser.add_argument("--port", type=int, default=4210, help="ESP32 UDP port (default: 4210)")
parser.add_argument("--beat-device", help="sounddevice input device name for beat detection")
args = parser.parse_args()
asyncio.run(async_main(args.esp32_host, args.port, args.beat_device))
if __name__ == "__main__":
main()