added json request and control interface
This commit is contained in:
+115
-28
@@ -1,64 +1,151 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
clients: set[asyncio.Queue[str]] = set()
|
||||
current_emotion = "neutral"
|
||||
|
||||
# Global state sent to clients
|
||||
state: Dict[str, Any] = {
|
||||
"emotion": "neutral",
|
||||
"intensity": 0.7, # 0..1
|
||||
"look": None, # {"x": -1..1, "y": -1..1} or None
|
||||
"mouth": {"open": False, "amount": 0.0, "duration_ms": 0},
|
||||
"talk": {"enabled": False, "rate_hz": 3.2, "amount": 0.9, "jitter": 0.25},
|
||||
}
|
||||
|
||||
def clamp(v: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
def sse(event: str, data: str) -> str:
|
||||
# SSE format: event + data + blank line
|
||||
return f"event: {event}\ndata: {data}\n\n"
|
||||
|
||||
def normalize_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
if "emotion" in patch:
|
||||
out["emotion"] = str(patch["emotion"])
|
||||
|
||||
if "intensity" in patch:
|
||||
try:
|
||||
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "look" in patch:
|
||||
look = patch["look"]
|
||||
if look is None:
|
||||
out["look"] = None
|
||||
elif isinstance(look, dict):
|
||||
try:
|
||||
x = clamp(float(look.get("x", 0.0)), -1.0, 1.0)
|
||||
y = clamp(float(look.get("y", 0.0)), -1.0, 1.0)
|
||||
out["look"] = {"x": x, "y": y}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "mouth" in patch and isinstance(patch["mouth"], dict):
|
||||
m = patch["mouth"]
|
||||
try:
|
||||
open_ = bool(m.get("open", False))
|
||||
amount = clamp(float(m.get("amount", 0.0)), 0.0, 1.0)
|
||||
duration_ms = int(m.get("duration_ms", 0))
|
||||
duration_ms = max(0, min(duration_ms, 10_000))
|
||||
out["mouth"] = {"open": open_, "amount": amount, "duration_ms": duration_ms}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# one-shot flags are allowed but not stored in state
|
||||
if "blink" in patch:
|
||||
out["blink"] = bool(patch["blink"])
|
||||
|
||||
if "talk" in patch:
|
||||
t = patch["talk"]
|
||||
if isinstance(t, dict):
|
||||
try:
|
||||
enabled = bool(t.get("enabled", False))
|
||||
rate_hz = float(t.get("rate_hz", 3.2))
|
||||
amount = float(t.get("amount", 0.9))
|
||||
jitter = float(t.get("jitter", 0.25))
|
||||
|
||||
rate_hz = clamp(rate_hz, 0.5, 10.0)
|
||||
amount = clamp(amount, 0.0, 1.0)
|
||||
jitter = clamp(jitter, 0.0, 1.0)
|
||||
|
||||
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
async def broadcast(payload: Dict[str, Any]) -> None:
|
||||
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
dead = []
|
||||
for q in clients:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
clients.discard(q)
|
||||
|
||||
@app.get("/events")
|
||||
async def events(request: Request):
|
||||
"""
|
||||
Browser connects here via EventSource. We stream emotion updates.
|
||||
"""
|
||||
q: asyncio.Queue[str] = asyncio.Queue()
|
||||
clients.add(q)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
# send current state immediately
|
||||
yield sse("emotion", current_emotion)
|
||||
# Send current state immediately on connect
|
||||
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
|
||||
|
||||
while True:
|
||||
# abort if client disconnected
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
msg = await q.get()
|
||||
yield sse("emotion", msg)
|
||||
yield sse("state", msg)
|
||||
finally:
|
||||
clients.discard(q)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # helpful behind nginx
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
@app.post("/api/state")
|
||||
async def set_state(patch: Dict[str, Any]):
|
||||
global state
|
||||
normalized = normalize_patch(patch)
|
||||
|
||||
# Merge persistent fields
|
||||
for k in ("emotion", "intensity", "look", "mouth", "talk"):
|
||||
if k in normalized:
|
||||
state[k] = normalized[k]
|
||||
|
||||
# Broadcast merged state + one-shot flags if any
|
||||
payload = dict(state)
|
||||
if "blink" in normalized:
|
||||
payload["blink"] = normalized["blink"]
|
||||
|
||||
await broadcast(payload)
|
||||
return JSONResponse({"ok": True, "state": state})
|
||||
|
||||
# Compatibility endpoint (optional): keeps your old curl calls working
|
||||
@app.post("/api/emotion/{name}")
|
||||
async def set_emotion(name: str):
|
||||
global current_emotion
|
||||
current_emotion = name
|
||||
global state
|
||||
state["emotion"] = name
|
||||
payload = dict(state)
|
||||
await broadcast(payload)
|
||||
return JSONResponse({"ok": True, "state": state})
|
||||
|
||||
# broadcast to all connected clients
|
||||
dead = []
|
||||
for q in clients:
|
||||
try:
|
||||
q.put_nowait(name)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
clients.discard(q)
|
||||
|
||||
return JSONResponse({"ok": True, "emotion": current_emotion})
|
||||
|
||||
@app.get("/api/emotion")
|
||||
async def get_emotion():
|
||||
return {"emotion": current_emotion}
|
||||
@app.get("/api/state")
|
||||
async def get_state():
|
||||
return {"state": state}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user