From c296d4e950a975659b5898965f9594e356a67314 Mon Sep 17 00:00:00 2001 From: max Date: Sun, 8 Feb 2026 20:57:08 +0100 Subject: [PATCH] additional new face stuff --- face/opt/face/face_server.py | 186 ++++++++------------ face/var/www/html/face.js | 319 ++--------------------------------- face/var/www/html/index.html | 1 + face/var/www/html/style.css | 1 + 4 files changed, 90 insertions(+), 417 deletions(-) diff --git a/face/opt/face/face_server.py b/face/opt/face/face_server.py index 867730d..e03247c 100755 --- a/face/opt/face/face_server.py +++ b/face/opt/face/face_server.py @@ -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} - diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js index d929b1d..87b1ea5 100644 --- a/face/var/www/html/face.js +++ b/face/var/www/html/face.js @@ -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(); })(); diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html index 53759f8..52bcff5 100755 --- a/face/var/www/html/index.html +++ b/face/var/www/html/index.html @@ -270,3 +270,4 @@ + diff --git a/face/var/www/html/style.css b/face/var/www/html/style.css index 139d422..9594e45 100755 --- a/face/var/www/html/style.css +++ b/face/var/www/html/style.css @@ -136,3 +136,4 @@ a.btn:active, button.btn:active{ transform: translateY(1px); } box-shadow: 0 0 0 3px rgba(255,107,107,.12); } +