additional new face stuff
This commit is contained in:
parent
a36981da66
commit
c296d4e950
|
|
@ -1,151 +1,115 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Set
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
clients: set[asyncio.Queue[str]] = set()
|
STATE: Dict[str, Any] = {
|
||||||
|
|
||||||
# Global state sent to clients
|
|
||||||
state: Dict[str, Any] = {
|
|
||||||
"emotion": "neutral",
|
"emotion": "neutral",
|
||||||
"intensity": 0.7, # 0..1
|
"intensity": 0.85,
|
||||||
"look": None, # {"x": -1..1, "y": -1..1} or None
|
"look": {"x": 0.0, "y": 0.0},
|
||||||
"mouth": {"open": False, "amount": 0.0, "duration_ms": 0},
|
"speaking": False,
|
||||||
"talk": {"enabled": False, "rate_hz": 3.2, "amount": 0.9, "jitter": 0.25},
|
"eyesMoving": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def clamp(v: float, lo: float, hi: float) -> float:
|
CLIENTS: Set[asyncio.Queue[str]] = set()
|
||||||
return max(lo, min(hi, v))
|
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]:
|
def clamp(v: Any, lo: float, hi: float) -> float:
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
if "emotion" in patch:
|
|
||||||
out["emotion"] = str(patch["emotion"])
|
|
||||||
|
|
||||||
if "intensity" in patch:
|
|
||||||
try:
|
try:
|
||||||
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
|
x = float(v)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return lo
|
||||||
|
return max(lo, min(hi, x))
|
||||||
|
|
||||||
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):
|
def merge_state(payload: Dict[str, Any]) -> None:
|
||||||
m = patch["mouth"]
|
if isinstance(payload.get("emotion"), str):
|
||||||
try:
|
STATE["emotion"] = payload["emotion"]
|
||||||
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 "intensity" in payload:
|
||||||
if "blink" in patch:
|
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
|
||||||
out["blink"] = bool(patch["blink"])
|
|
||||||
|
|
||||||
if "talk" in patch:
|
if isinstance(payload.get("look"), dict):
|
||||||
t = patch["talk"]
|
lx = clamp(payload["look"].get("x", 0.0), -1.0, 1.0)
|
||||||
if isinstance(t, dict):
|
ly = clamp(payload["look"].get("y", 0.0), -1.0, 1.0)
|
||||||
try:
|
STATE["look"] = {"x": lx, "y": ly}
|
||||||
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)
|
if isinstance(payload.get("speaking"), bool):
|
||||||
amount = clamp(amount, 0.0, 1.0)
|
STATE["speaking"] = payload["speaking"]
|
||||||
jitter = clamp(jitter, 0.0, 1.0)
|
|
||||||
|
|
||||||
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter}
|
if isinstance(payload.get("eyesMoving"), bool):
|
||||||
except Exception:
|
STATE["eyesMoving"] = payload["eyesMoving"]
|
||||||
pass
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
async def broadcast(payload: Dict[str, Any]) -> None:
|
async def broadcast() -> None:
|
||||||
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
msg = json.dumps(STATE, separators=(",", ":"))
|
||||||
dead = []
|
dead: list[asyncio.Queue[str]] = []
|
||||||
for q in clients:
|
|
||||||
|
async with LOCK:
|
||||||
|
for q in CLIENTS:
|
||||||
try:
|
try:
|
||||||
q.put_nowait(msg)
|
q.put_nowait(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
dead.append(q)
|
dead.append(q)
|
||||||
for q in dead:
|
for q in dead:
|
||||||
clients.discard(q)
|
CLIENTS.discard(q)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/state")
|
||||||
|
async def get_state():
|
||||||
|
return JSONResponse(STATE)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/state")
|
||||||
|
async def set_state(payload: Dict[str, Any]):
|
||||||
|
merge_state(payload)
|
||||||
|
await broadcast()
|
||||||
|
return JSONResponse({"ok": True, "state": STATE})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/events")
|
@app.get("/events")
|
||||||
async def events(request: Request):
|
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():
|
async def gen():
|
||||||
try:
|
try:
|
||||||
# Send current state immediately on connect
|
# initial state immediately
|
||||||
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
|
initial = json.dumps(STATE, separators=(",", ":"))
|
||||||
|
yield f"event: state\ndata: {initial}\n\n"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
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:
|
finally:
|
||||||
clients.discard(q)
|
async with LOCK:
|
||||||
|
CLIENTS.discard(q)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no", # important behind nginx
|
||||||
}
|
}
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
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}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,312 +1,19 @@
|
||||||
// Helva Display Face (ONLY visor/display) - state driven
|
(function connectSSE(){
|
||||||
// Supports payload like:
|
let es;
|
||||||
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0}}
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
const OVERLAY_TIMEOUT_MS = 3500;
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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() {
|
function open() {
|
||||||
ws = new WebSocket(wsUrl);
|
es = new EventSource("/events");
|
||||||
ws.onopen = () => window.setConnected(true);
|
es.onopen = () => window.setConnected(true);
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
|
es.addEventListener("state", (ev) => {
|
||||||
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
|
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
window.setConnected(false);
|
||||||
|
try { es.close(); } catch (_) {}
|
||||||
|
setTimeout(open, 1200);
|
||||||
};
|
};
|
||||||
ws.onclose = () => { window.setConnected(false); setTimeout(open, 800); };
|
|
||||||
ws.onerror = () => { try { ws.close(); } catch (_) {} };
|
|
||||||
}
|
}
|
||||||
open();
|
open();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Init
|
|
||||||
updateControlLink();
|
|
||||||
applyIntensity(state.intensity);
|
|
||||||
setConnectedUI(true);
|
|
||||||
setEmotionUI("neutral");
|
|
||||||
setSpeakingUI(false);
|
|
||||||
setEyesMovingUI(true);
|
|
||||||
scheduleBlink();
|
|
||||||
scheduleEyeDrift();
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,3 +270,4 @@
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,3 +136,4 @@ a.btn:active, button.btn:active{ transform: translateY(1px); }
|
||||||
box-shadow: 0 0 0 3px rgba(255,107,107,.12);
|
box-shadow: 0 0 0 3px rgba(255,107,107,.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue