additional new face stuff
This commit is contained in:
parent
a36981da66
commit
c296d4e950
|
|
@ -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] = {}
|
||||
|
||||
if "emotion" in patch:
|
||||
out["emotion"] = str(patch["emotion"])
|
||||
|
||||
if "intensity" in patch:
|
||||
def clamp(v: Any, lo: float, hi: float) -> float:
|
||||
try:
|
||||
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
|
||||
x = float(v)
|
||||
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):
|
||||
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
|
||||
def merge_state(payload: Dict[str, Any]) -> None:
|
||||
if isinstance(payload.get("emotion"), str):
|
||||
STATE["emotion"] = payload["emotion"]
|
||||
|
||||
# one-shot flags are allowed but not stored in state
|
||||
if "blink" in patch:
|
||||
out["blink"] = bool(patch["blink"])
|
||||
if "intensity" in payload:
|
||||
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
|
||||
|
||||
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))
|
||||
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}
|
||||
|
||||
rate_hz = clamp(rate_hz, 0.5, 10.0)
|
||||
amount = clamp(amount, 0.0, 1.0)
|
||||
jitter = clamp(jitter, 0.0, 1.0)
|
||||
if isinstance(payload.get("speaking"), bool):
|
||||
STATE["speaking"] = payload["speaking"]
|
||||
|
||||
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter}
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(payload.get("eyesMoving"), bool):
|
||||
STATE["eyesMoving"] = payload["eyesMoving"]
|
||||
|
||||
return out
|
||||
|
||||
async def broadcast(payload: Dict[str, Any]) -> None:
|
||||
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
dead = []
|
||||
for q in clients:
|
||||
async def broadcast() -> None:
|
||||
msg = json.dumps(STATE, separators=(",", ":"))
|
||||
dead: list[asyncio.Queue[str]] = []
|
||||
|
||||
async with LOCK:
|
||||
for q in CLIENTS:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
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")
|
||||
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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
||||
(() => {
|
||||
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 connectSSE(){
|
||||
let es;
|
||||
function open() {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onopen = () => window.setConnected(true);
|
||||
ws.onmessage = (ev) => {
|
||||
es = new EventSource("/events");
|
||||
es.onopen = () => window.setConnected(true);
|
||||
|
||||
es.addEventListener("state", (ev) => {
|
||||
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();
|
||||
})();
|
||||
|
||||
// Init
|
||||
updateControlLink();
|
||||
applyIntensity(state.intensity);
|
||||
setConnectedUI(true);
|
||||
setEmotionUI("neutral");
|
||||
setSpeakingUI(false);
|
||||
setEyesMovingUI(true);
|
||||
scheduleBlink();
|
||||
scheduleEyeDrift();
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -270,3 +270,4 @@
|
|||
</body>
|
||||
</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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue