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

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}

View File

@ -1,312 +1,19 @@
// Helva Display Face (ONLY visor/display) - state driven
// Supports payload like:
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0}}
(function connectSSE(){
let es;
function open() {
es = new EventSource("/events");
es.onopen = () => window.setConnected(true);
(() => {
const OVERLAY_TIMEOUT_MS = 3500;
es.addEventListener("state", (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
});
const state = {
emotion: "neutral",
intensity: 0.7, // 0..1
look: { x: 0, y: 0 }, // -1..1
lookLock: false, // if true: no drift (stare)
speaking: false,
eyesMoving: true,
connected: true,
};
// DOM
const stage = document.getElementById("stage");
const overlay = document.getElementById("overlay");
const dotConn = document.getElementById("dotConn");
const pillText = document.getElementById("pillText");
const emotionLabel = document.getElementById("emotionLabel");
const emotionControlLink = document.getElementById("emotionControlLink");
const btnSpeak = document.getElementById("btnSpeak");
const btnEyes = document.getElementById("btnEyes");
const speakState = document.getElementById("speakState");
const eyesState = document.getElementById("eyesState");
const mouthShape = document.getElementById("mouthShape");
const mouthInner = document.getElementById("mouthInner");
const statusLed = document.getElementById("statusLed");
const cheekL = document.getElementById("cheekL");
const cheekR = document.getElementById("cheekR");
const eyeLIris = document.querySelector("#eyeL .iris");
const eyeRIris = document.querySelector("#eyeR .iris");
const pupilL = document.querySelector("#eyeL .pupil");
const pupilR = document.querySelector("#eyeR .pupil");
const lidL = document.getElementById("lidL");
const lidR = document.getElementById("lidR");
const mouthGroup = document.getElementById("mouth");
// Overlay on touch
let overlayTimer = null;
function showOverlay() {
overlay.classList.add("show");
clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
}
stage.addEventListener("pointerdown", () => showOverlay(), { passive: true });
btnSpeak.addEventListener("click", (e) => { e.stopPropagation(); window.setSpeaking(!state.speaking); showOverlay(); });
btnEyes.addEventListener("click", (e) => { e.stopPropagation(); window.setEyesMoving(!state.eyesMoving); showOverlay(); });
// Helpers
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function clamp01(v) {
const n = Number(v);
if (Number.isNaN(n)) return 0;
return clamp(n, 0, 1);
}
// Emotions
const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]);
function updateControlLink() {
emotionLabel.textContent = state.emotion;
emotionControlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`;
}
function setEyeOffset(dx, dy) {
const max = 10;
const x = clamp(dx, -max, max);
const y = clamp(dy, -max, max);
// base positions: L(200,170), R(320,170)
eyeLIris.setAttribute("cx", 200 + x);
eyeLIris.setAttribute("cy", 170 + y);
eyeRIris.setAttribute("cx", 320 + x);
eyeRIris.setAttribute("cy", 170 + y);
pupilL.setAttribute("cx", 200 + x);
pupilL.setAttribute("cy", 170 + y);
pupilR.setAttribute("cx", 320 + x);
pupilR.setAttribute("cy", 170 + y);
}
function applyLook(look, lock = true) {
if (!look) return;
const x = clamp(Number(look.x ?? 0), -1, 1);
const y = clamp(Number(look.y ?? 0), -1, 1);
state.look = { x, y };
state.lookLock = !!lock;
setEyeOffset(x * 10, y * 10);
}
function applyIntensity(intensity) {
state.intensity = clamp01(intensity);
// LED brightness
statusLed.setAttribute("opacity", String(0.35 + state.intensity * 0.65));
// cheeks baseline
cheekL.style.opacity = String(0.12 + 0.55 * state.intensity);
cheekR.style.opacity = String(0.12 + 0.55 * state.intensity);
}
function setEmotionUI(em) {
const emotion = EMOTIONS.has(em) ? em : "neutral";
state.emotion = emotion;
updateControlLink();
const mouths = {
neutral: {
outer: "M225,250 C245,262 275,262 295,250 C290,275 230,275 225,250Z",
inner: "M238,258 C250,267 270,267 282,258 C275,272 245,272 238,258Z"
},
happy: {
outer: "M215,242 C240,292 280,292 305,242 C295,310 225,310 215,242Z",
inner: "M232,265 C250,288 270,288 288,265 C280,298 240,298 232,265Z"
},
sad: {
outer: "M225,282 C245,260 275,260 295,282 C285,245 235,245 225,282Z",
inner: "M240,276 C250,265 270,265 280,276 C272,258 248,258 240,276Z"
},
angry: {
outer: "M220,270 C255,255 265,255 300,270 C292,292 228,292 220,270Z",
inner: "M240,272 C255,264 265,264 280,272 C274,283 246,283 240,272Z"
},
sleepy: {
outer: "M230,280 C250,286 270,286 290,280 C283,292 237,292 230,280Z",
inner: "M244,282 C252,286 268,286 276,282 C270,290 250,290 244,282Z"
},
surprised: {
outer: "M240,238 C260,228 260,228 280,238 C305,265 305,298 260,305 C215,298 215,265 240,238Z",
inner: "M248,254 C260,246 260,246 272,254 C288,270 288,286 260,291 C232,286 232,270 248,254Z"
},
excited: {
outer: "M210,242 C240,322 280,322 310,242 C300,336 220,336 210,242Z",
inner: "M228,274 C250,308 270,308 292,274 C284,322 236,322 228,274Z"
}
es.onerror = () => {
window.setConnected(false);
try { es.close(); } catch (_) {}
setTimeout(open, 1200);
};
const m = mouths[emotion] ?? mouths.neutral;
mouthShape.setAttribute("d", m.outer);
mouthInner.setAttribute("d", m.inner);
// LED color hint per emotion
switch (emotion) {
case "sad":
case "sleepy":
statusLed.setAttribute("fill", "rgba(124,183,255,.90)");
break;
case "angry":
statusLed.setAttribute("fill", "rgba(255,107,107,.92)");
break;
default:
statusLed.setAttribute("fill", "rgba(124,255,201,.92)");
}
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setConnectedUI(on) {
state.connected = !!on;
dotConn.classList.toggle("on", state.connected);
dotConn.classList.toggle("off", !state.connected);
pillText.textContent = state.connected ? (state.speaking ? (state.emotion + " • speaking") : state.emotion) : "offline";
}
function setSpeakingUI(on) {
state.speaking = !!on;
speakState.textContent = state.speaking ? "an" : "aus";
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setEyesMovingUI(on) {
state.eyesMoving = !!on;
eyesState.textContent = state.eyesMoving ? "an" : "aus";
}
// Animations: blink + drift + speaking flap
let blinkTimer = null;
let driftTimer = null;
let speakTimer = null;
function doBlink() {
const down = 70, up = 90;
lidL.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
lidR.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
setTimeout(() => {
lidL.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
lidR.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
}, down + 25);
}
function scheduleBlink() {
clearTimeout(blinkTimer);
const base = state.emotion === "sleepy" ? 4500 : 2600;
blinkTimer = setTimeout(() => { doBlink(); scheduleBlink(); }, base + Math.random() * 2200);
}
function scheduleEyeDrift() {
clearTimeout(driftTimer);
if (state.lookLock) {
setEyeOffset(state.look.x * 10, state.look.y * 10);
return;
}
if (!state.eyesMoving) {
setEyeOffset(0,0);
return;
}
const dx = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const dy = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const startX = (parseFloat(eyeLIris.getAttribute("cx")) - 200) || 0;
const startY = (parseFloat(eyeLIris.getAttribute("cy")) - 170) || 0;
const steps = 18;
let i = 0;
const tick = () => {
i++;
const t = i / steps;
const ease = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2;
setEyeOffset(startX + (dx - startX)*ease, startY + (dy - startY)*ease);
if (i < steps) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
driftTimer = setTimeout(scheduleEyeDrift, 700 + Math.random() * 700);
}
function startSpeakingFlap() {
stopSpeakingFlap();
let open = false;
const interval = (state.emotion === "excited") ? 90 : (state.emotion === "sleepy" ? 170 : 120);
speakTimer = setInterval(() => {
if (!state.speaking) return;
open = !open;
const s = open ? 1.12 : 0.96;
mouthGroup.setAttribute("transform", `translate(0, ${open ? -2 : 0}) scale(1, ${s})`);
}, interval);
}
function stopSpeakingFlap() {
if (speakTimer) clearInterval(speakTimer);
speakTimer = null;
mouthGroup.setAttribute("transform", "translate(0,0)");
}
// Public API
window.setEmotion = (emotion) => { setEmotionUI(emotion); scheduleBlink(); scheduleEyeDrift(); };
window.setSpeaking = (on) => { setSpeakingUI(on); if (state.speaking) startSpeakingFlap(); else stopSpeakingFlap(); };
window.setEyesMoving = (on) => { setEyesMovingUI(on); scheduleEyeDrift(); };
window.setConnected = (on) => { setConnectedUI(on); };
window.applyFaceState = (payload) => {
if (!payload || typeof payload !== "object") return;
if (payload.intensity !== undefined) applyIntensity(payload.intensity);
if (typeof payload.emotion === "string") setEmotionUI(payload.emotion);
if (payload.look && typeof payload.look === "object") {
applyLook(payload.look, true); // lock stare
} else {
state.lookLock = false;
scheduleEyeDrift();
}
if (typeof payload.speaking === "boolean") window.setSpeaking(payload.speaking);
if (typeof payload.eyesMoving === "boolean") window.setEyesMoving(payload.eyesMoving);
if (typeof payload.connected === "boolean") window.setConnected(payload.connected);
scheduleBlink();
if (state.speaking) startSpeakingFlap();
};
// Optional WebSocket to /ws (if present)
(function connectWS(){
const proto = (location.protocol === "https:") ? "wss" : "ws";
const wsUrl = `${proto}://${location.host}/ws`;
let ws;
function open() {
ws = new WebSocket(wsUrl);
ws.onopen = () => window.setConnected(true);
ws.onmessage = (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
};
ws.onclose = () => { window.setConnected(false); setTimeout(open, 800); };
ws.onerror = () => { try { ws.close(); } catch (_) {} };
}
open();
})();
// Init
updateControlLink();
applyIntensity(state.intensity);
setConnectedUI(true);
setEmotionUI("neutral");
setSpeakingUI(false);
setEyesMovingUI(true);
scheduleBlink();
scheduleEyeDrift();
open();
})();

View File

@ -270,3 +270,4 @@
</body>
</html>

View File

@ -136,3 +136,4 @@ a.btn:active, button.btn:active{ transform: translateY(1px); }
box-shadow: 0 0 0 3px rgba(255,107,107,.12);
}