diff --git a/face/opt/face/face_server.py b/face/opt/face/face_server.py index dfec0ec..867730d 100755 --- a/face/opt/face/face_server.py +++ b/face/opt/face/face_server.py @@ -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} diff --git a/face/var/www/html/app.js b/face/var/www/html/app.js index 1288081..4a112cc 100755 --- a/face/var/www/html/app.js +++ b/face/var/www/html/app.js @@ -3,6 +3,14 @@ const eyes = Array.from(document.querySelectorAll(".eye")); const mouthShape = document.querySelector(".mouth-shape"); let current = "neutral"; +let wanderEnabled = true; +let wanderTimer = null; + +let talkEnabled = false; +let talkCfg = { rate_hz: 3.2, amount: 0.9, jitter: 0.25 }; +let talkTimer = null; + +function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); } function setEmotion(name) { const safe = String(name || "neutral") @@ -13,62 +21,151 @@ function setEmotion(name) { document.body.className = `emotion-${safe}`; label.textContent = safe; - // Sonderfall: surprised -> O-Mund aktivieren + // surprised -> O-mouth if (safe === "surprised") document.body.classList.add("has-omouth"); else document.body.classList.remove("has-omouth"); - // sad -> frown pseudo aktivieren (eigene Klasse) + // frown for sad/angry if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown"); else mouthShape.classList.remove("frown"); } -/** Pupillen bewegen sich sanft, damit es "lebendig" wirkt */ -function startPupilWander() { - const tick = () => { - // kleine zufällige Offsets in Pixel (auf großen Displays reicht das) - const x = Math.round((Math.random() * 2 - 1) * 10); - const y = Math.round((Math.random() * 2 - 1) * 8); - - document.documentElement.style.setProperty("--pupil-x", `${x}px`); - document.documentElement.style.setProperty("--pupil-y", `${y}px`); - - // alle 600-1400ms neu - const next = 600 + Math.random() * 800; - setTimeout(tick, next); - }; - tick(); -} - -/** Blinzeln: in zufälligen Abständen, manchmal doppelt */ function blinkOnce() { eyes.forEach(e => e.classList.add("blink")); setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120); } -function startBlinking() { - const loop = () => { - // sleepy blinzelt öfter/langsamer, angry etwas "härter" - let base = 3500; - if (current === "sleepy") base = 2200; - if (current === "surprised") base = 4200; +function setLook(x, y) { + // x/y in -1..1 -> px offsets + const pxX = Math.round(clamp(x, -1, 1) * 12); + const pxY = Math.round(clamp(y, -1, 1) * 10); + document.documentElement.style.setProperty("--pupil-x", `${pxX}px`); + document.documentElement.style.setProperty("--pupil-y", `${pxY}px`); +} - const next = base + Math.random() * 2200; - setTimeout(() => { - blinkOnce(); +function setIntensity(v) { + const val = clamp(Number(v ?? 0.7), 0, 1); + document.documentElement.style.setProperty("--intensity", String(val)); +} - // 15% chance auf Doppelt-Blink - if (Math.random() < 0.15) setTimeout(blinkOnce, 220); +function setMouthOpen(v) { + const val = clamp(Number(v ?? 0), 0, 1); + document.documentElement.style.setProperty("--mouth-open", String(val)); +} - loop(); - }, next); +function speak(amount = 0.8, durationMs = 600) { + if (talkEnabled) return; // Talk-Mode steuert Mund + setMouthOpen(amount); + setTimeout(() => setMouthOpen(0), Math.max(0, durationMs)); +} + +function stopTalk() { + talkEnabled = false; + if (talkTimer) { + clearTimeout(talkTimer); + talkTimer = null; + } + setMouthOpen(0); +} + +function startTalk(cfg) { + talkEnabled = true; + talkCfg = { + rate_hz: clamp(Number(cfg?.rate_hz ?? 3.2), 0.5, 10), + amount: clamp(Number(cfg?.amount ?? 0.9), 0, 1), + jitter: clamp(Number(cfg?.jitter ?? 0.25), 0, 1), }; - loop(); + + if (talkTimer) clearTimeout(talkTimer); + + // “sprech”-Animation: Mund öffnet/schließt schnell, mit bisschen Zufall + const tick = () => { + if (!talkEnabled) return; + + const base = talkCfg.amount; + const j = talkCfg.jitter; + + // random-ish open amount between ~0.2..1.0, scaled + const r = (0.35 + Math.random() * 0.65); + const open = clamp(base * r * (1 - j + Math.random() * j), 0, 1); + setMouthOpen(open); + + // timing from rate_hz (Hz -> ms) + const interval = Math.max(60, Math.round(1000 / talkCfg.rate_hz)); + // add a bit of jitter to cadence + const next = interval + Math.round((Math.random() * 2 - 1) * interval * 0.25); + + talkTimer = setTimeout(tick, next); + }; + + tick(); +} + + +/* Pupillen-Wandern nur wenn kein look gesetzt ist */ +function startWander() { + if (wanderTimer) clearTimeout(wanderTimer); + + const tick = () => { + if (!wanderEnabled) return; + + const x = (Math.random() * 2 - 1) * 0.7; + const y = (Math.random() * 2 - 1) * 0.6; + setLook(x, y); + + const next = 600 + Math.random() * 900; + wanderTimer = setTimeout(tick, next); + }; + tick(); +} + +function applyState(s) { + if (!s || typeof s !== "object") return; + + if (s.emotion) setEmotion(s.emotion); + if (s.intensity !== undefined) setIntensity(s.intensity); + + // one-shot blink + if (s.blink) blinkOnce(); + + // look: if null -> enable wander. if object -> fixed look + if ("look" in s) { + if (s.look === null) { + wanderEnabled = true; + startWander(); + } else if (typeof s.look === "object") { + wanderEnabled = false; + setLook(s.look.x ?? 0, s.look.y ?? 0); + } + } + + // mouth command + if (s.mouth && typeof s.mouth === "object") { + const open = !!s.mouth.open; + const amount = clamp(Number(s.mouth.amount ?? 0.8), 0, 1); + const dur = Number(s.mouth.duration_ms ?? 600); + + if (open) speak(amount, dur); + else setMouthOpen(0); + } + + // talk mode + if (s.talk && typeof s.talk === "object") { + const enabled = !!s.talk.enabled; + if (enabled) startTalk(s.talk); + else stopTalk(); + } } function connect() { const es = new EventSource("/events"); - es.addEventListener("emotion", (e) => setEmotion(e.data)); - es.onmessage = (e) => setEmotion(e.data); // fallback + es.addEventListener("state", (e) => { + try { applyState(JSON.parse(e.data)); } catch {} + }); + es.onmessage = (e) => { + // fallback: treat as state json + try { applyState(JSON.parse(e.data)); } catch {} + }; es.onerror = () => { es.close(); setTimeout(connect, 1000); @@ -76,6 +173,22 @@ function connect() { } connect(); -startPupilWander(); -startBlinking(); +startWander(); + +/* zufälliges Blinzeln unabhängig vom Push */ +(function startBlinkLoop(){ + const loop = () => { + let base = 3500; + if (current === "sleepy") base = 2200; + if (current === "surprised") base = 4200; + + const next = base + Math.random() * 2200; + setTimeout(() => { + blinkOnce(); + if (Math.random() < 0.12) setTimeout(blinkOnce, 220); + loop(); + }, next); + }; + loop(); +})(); diff --git a/face/var/www/html/control/index.html b/face/var/www/html/control/index.html new file mode 100644 index 0000000..c07f7ab --- /dev/null +++ b/face/var/www/html/control/index.html @@ -0,0 +1,391 @@ + + +
+ + +