feat(05-01): add per-connection show state and load handler to ws.py
- Import encode_animation_cmd and ShowModel at module level - Add show, fired_ids, is_playing per-connection state variables - Extend load handler to load show from show_store by show_id (D-04) - play handler sets is_playing=True - pause handler sets is_playing=False without touching fired_ids - seek handler rebuilds fired_ids from show cues before seek position (D-03)
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
"""WebSocket hub — browser-driven tick/beat protocol (Phase 02-03 redesign).
|
"""WebSocket hub — browser-driven tick/beat protocol (Phase 02-03 redesign).
|
||||||
|
|
||||||
Browser sends position ticks at ~10Hz; server looks up the next beat and
|
Browser sends position ticks at ~10Hz; server looks up the next beat and
|
||||||
sends a beat notification back when one is near.
|
sends a beat notification back when one is near. Phase 5 adds cue scheduling:
|
||||||
|
the tick handler fires UDP animation commands at correct timestamps.
|
||||||
|
|
||||||
Protocol (browser → server):
|
Protocol (browser → server):
|
||||||
{"type": "tick", "position": 42.3} — audio playback position
|
{"type": "tick", "position": 42.3} — audio playback position
|
||||||
{"type": "play"} — playback started (log only for now)
|
{"type": "play", "position": 42.3} — playback started
|
||||||
{"type": "pause"} — playback paused (log only for now)
|
{"type": "pause", "position": 42.3} — playback paused
|
||||||
{"type": "seek", "position": 42.3} — seek event (log only for now)
|
{"type": "seek", "position": 42.3} — seek event
|
||||||
|
{"type": "load", "path": "...", "show_id": "uuid"} — file + show loaded
|
||||||
|
|
||||||
Protocol (server → browser):
|
Protocol (server → browser):
|
||||||
{"type": "beat", "position": 42.3, "tempo": 120.0} — beat detected near position
|
{"type": "beat", "position": 42.3, "tempo": 120.0} — beat detected near position
|
||||||
|
{"type": "preview_update", "device_id": "uuid", "animation": "...", "color": [...]}
|
||||||
{"type": "error", "message": "..."} — error response
|
{"type": "error", "message": "..."} — error response
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,6 +22,8 @@ import bisect
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from lightsync.protocol.animation_cmd import encode_animation_cmd
|
||||||
|
from lightsync.models.show import ShowModel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,6 +86,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
tempo: float = 0.0
|
tempo: float = 0.0
|
||||||
last_beat_reported: float | None = None # avoid re-reporting same beat
|
last_beat_reported: float | None = None # avoid re-reporting same beat
|
||||||
|
|
||||||
|
# Per-connection cue scheduler state (Phase 5 — D-04)
|
||||||
|
show: ShowModel | None = None # loaded show for cue scheduling
|
||||||
|
fired_ids: set[str] = set() # cues already dispatched this playback pass
|
||||||
|
is_playing: bool = False # gate cue scheduling on play state
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
@@ -122,14 +132,24 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
elif msg_type == "play":
|
elif msg_type == "play":
|
||||||
logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0)))
|
logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0)))
|
||||||
last_beat_reported = None # reset beat tracking on play
|
last_beat_reported = None # reset beat tracking on play
|
||||||
|
is_playing = True # enable cue scheduling
|
||||||
|
|
||||||
elif msg_type == "pause":
|
elif msg_type == "pause":
|
||||||
logger.info("[ws] pause event at position=%.3f", float(msg.get("position", 0)))
|
logger.info("[ws] pause event at position=%.3f", float(msg.get("position", 0)))
|
||||||
|
is_playing = False # disable cue scheduling; do NOT modify fired_ids
|
||||||
|
|
||||||
elif msg_type == "seek":
|
elif msg_type == "seek":
|
||||||
position = float(msg.get("position", 0.0))
|
position = float(msg.get("position", 0.0))
|
||||||
logger.info("[ws] seek to position=%.3f", position)
|
logger.info("[ws] seek to position=%.3f", position)
|
||||||
last_beat_reported = None # reset so beats near seek target fire
|
last_beat_reported = None # reset so beats near seek target fire
|
||||||
|
# Rebuild fired_ids: mark everything before seek position as fired (D-03)
|
||||||
|
if show is not None:
|
||||||
|
fired_ids = {
|
||||||
|
str(cue.id)
|
||||||
|
for track in show.tracks
|
||||||
|
for cue in track.cues
|
||||||
|
if cue.timestamp < position - 0.060
|
||||||
|
}
|
||||||
|
|
||||||
elif msg_type == "load":
|
elif msg_type == "load":
|
||||||
# Browser signals which file is now playing — update beat cache lookup
|
# Browser signals which file is now playing — update beat cache lookup
|
||||||
@@ -146,6 +166,19 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
else:
|
else:
|
||||||
logger.warning("[ws] no beat data cached for %s", path)
|
logger.warning("[ws] no beat data cached for %s", path)
|
||||||
|
|
||||||
|
# Show cue list loading (Phase 5 — D-04)
|
||||||
|
show_id = msg.get("show_id")
|
||||||
|
if show_id:
|
||||||
|
import lightsync.main as _main
|
||||||
|
loaded = await _main.show_store.load(show_id)
|
||||||
|
if loaded:
|
||||||
|
show = loaded
|
||||||
|
fired_ids = set()
|
||||||
|
is_playing = False
|
||||||
|
logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks))
|
||||||
|
else:
|
||||||
|
logger.warning("[ws] show %s not found", show_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unknown message type — echo back for debugging
|
# Unknown message type — echo back for debugging
|
||||||
await websocket.send_text(json.dumps({"type": "ack", "echo": msg}))
|
await websocket.send_text(json.dumps({"type": "ack", "echo": msg}))
|
||||||
|
|||||||
Reference in New Issue
Block a user