additional new face stuff

This commit is contained in:
max
2026-02-08 20:57:08 +01:00
parent a36981da66
commit c296d4e950
4 changed files with 90 additions and 417 deletions
+75 -111
View File
@@ -1,151 +1,115 @@
from __future__ import annotations
import asyncio
import json
from typing import Any, Dict, Optional
from typing import Any, Dict, Set
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
app = FastAPI()
clients: set[asyncio.Queue[str]] = set()
# Global state sent to clients
state: Dict[str, Any] = {
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},
"intensity": 0.85,
"look": {"x": 0.0, "y": 0.0},
"speaking": False,
"eyesMoving": True,
}
def clamp(v: float, lo: float, hi: float) -> float:
return max(lo, min(hi, v))
CLIENTS: Set[asyncio.Queue[str]] = set()
LOCK = asyncio.Lock()
def sse(event: str, data: str) -> str:
return f"event: {event}\ndata: {data}\n\n"
def normalize_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
def clamp(v: Any, lo: float, hi: float) -> float:
try:
x = float(v)
except Exception:
return lo
return max(lo, min(hi, x))
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
def merge_state(payload: Dict[str, Any]) -> None:
if isinstance(payload.get("emotion"), str):
STATE["emotion"] = payload["emotion"]
if "look" in patch:
look = patch["look"]
if look is None:
out["look"] = None
elif isinstance(look, dict):
if "intensity" in payload:
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
if isinstance(payload.get("look"), dict):
lx = clamp(payload["look"].get("x", 0.0), -1.0, 1.0)
ly = clamp(payload["look"].get("y", 0.0), -1.0, 1.0)
STATE["look"] = {"x": lx, "y": ly}
if isinstance(payload.get("speaking"), bool):
STATE["speaking"] = payload["speaking"]
if isinstance(payload.get("eyesMoving"), bool):
STATE["eyesMoving"] = payload["eyesMoving"]
async def broadcast() -> None:
msg = json.dumps(STATE, separators=(",", ":"))
dead: list[asyncio.Queue[str]] = []
async with LOCK:
for q in CLIENTS:
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}
q.put_nowait(msg)
except Exception:
pass
dead.append(q)
for q in dead:
CLIENTS.discard(q)
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"])
@app.get("/state")
async def get_state():
return JSONResponse(STATE)
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)
@app.post("/state")
async def set_state(payload: Dict[str, Any]):
merge_state(payload)
await broadcast()
return JSONResponse({"ok": True, "state": STATE})
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):
q: asyncio.Queue[str] = asyncio.Queue()
clients.add(q)
"""
SSE stream for browser:
const es = new EventSource("/events");
nginx should proxy /events -> http://127.0.0.1:8001/events
"""
q: asyncio.Queue[str] = asyncio.Queue(maxsize=50)
async with LOCK:
CLIENTS.add(q)
async def gen():
try:
# Send current state immediately on connect
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
# initial state immediately
initial = json.dumps(STATE, separators=(",", ":"))
yield f"event: state\ndata: {initial}\n\n"
while True:
if await request.is_disconnected():
break
msg = await q.get()
yield sse("state", msg)
try:
msg = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"event: state\ndata: {msg}\n\n"
except asyncio.TimeoutError:
# keepalive
yield ": keepalive\n\n"
finally:
clients.discard(q)
async with LOCK:
CLIENTS.discard(q)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Accel-Buffering": "no", # important behind nginx
}
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 state
state["emotion"] = name
payload = dict(state)
await broadcast(payload)
return JSONResponse({"ok": True, "state": state})
@app.get("/api/state")
async def get_state():
return {"state": state}