added json request and control interface

This commit is contained in:
Helva
2026-01-30 23:43:30 +01:00
parent 04c5db785a
commit 9c9277b346
4 changed files with 682 additions and 66 deletions
+115 -28
View File
@@ -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}