added json request and control interface

This commit is contained in:
Helva 2026-01-30 23:43:30 +01:00
parent 04c5db785a
commit 9c9277b346
4 changed files with 682 additions and 66 deletions

View File

@ -1,64 +1,151 @@
import asyncio import asyncio
import json
from typing import Any, Dict, Optional
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import StreamingResponse, JSONResponse
app = FastAPI() app = FastAPI()
clients: set[asyncio.Queue[str]] = set() 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: def sse(event: str, data: str) -> str:
# SSE format: event + data + blank line
return f"event: {event}\ndata: {data}\n\n" 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") @app.get("/events")
async def events(request: Request): async def events(request: Request):
"""
Browser connects here via EventSource. We stream emotion updates.
"""
q: asyncio.Queue[str] = asyncio.Queue() q: asyncio.Queue[str] = asyncio.Queue()
clients.add(q) clients.add(q)
async def gen(): async def gen():
try: try:
# send current state immediately # Send current state immediately on connect
yield sse("emotion", current_emotion) yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
while True: while True:
# abort if client disconnected
if await request.is_disconnected(): if await request.is_disconnected():
break break
msg = await q.get() msg = await q.get()
yield sse("emotion", msg) yield sse("state", msg)
finally: finally:
clients.discard(q) clients.discard(q)
headers = { headers = {
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no", # helpful behind nginx "X-Accel-Buffering": "no",
} }
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}") @app.post("/api/emotion/{name}")
async def set_emotion(name: str): async def set_emotion(name: str):
global current_emotion global state
current_emotion = name state["emotion"] = name
payload = dict(state)
await broadcast(payload)
return JSONResponse({"ok": True, "state": state})
# broadcast to all connected clients @app.get("/api/state")
dead = [] async def get_state():
for q in clients: return {"state": state}
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}

View File

@ -3,6 +3,14 @@ const eyes = Array.from(document.querySelectorAll(".eye"));
const mouthShape = document.querySelector(".mouth-shape"); const mouthShape = document.querySelector(".mouth-shape");
let current = "neutral"; 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) { function setEmotion(name) {
const safe = String(name || "neutral") const safe = String(name || "neutral")
@ -13,62 +21,151 @@ function setEmotion(name) {
document.body.className = `emotion-${safe}`; document.body.className = `emotion-${safe}`;
label.textContent = safe; label.textContent = safe;
// Sonderfall: surprised -> O-Mund aktivieren // surprised -> O-mouth
if (safe === "surprised") document.body.classList.add("has-omouth"); if (safe === "surprised") document.body.classList.add("has-omouth");
else document.body.classList.remove("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"); if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
else mouthShape.classList.remove("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() { function blinkOnce() {
eyes.forEach(e => e.classList.add("blink")); eyes.forEach(e => e.classList.add("blink"));
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120); setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
} }
function startBlinking() { function setLook(x, y) {
const loop = () => { // x/y in -1..1 -> px offsets
// sleepy blinzelt öfter/langsamer, angry etwas "härter" const pxX = Math.round(clamp(x, -1, 1) * 12);
let base = 3500; const pxY = Math.round(clamp(y, -1, 1) * 10);
if (current === "sleepy") base = 2200; document.documentElement.style.setProperty("--pupil-x", `${pxX}px`);
if (current === "surprised") base = 4200; document.documentElement.style.setProperty("--pupil-y", `${pxY}px`);
}
const next = base + Math.random() * 2200; function setIntensity(v) {
setTimeout(() => { const val = clamp(Number(v ?? 0.7), 0, 1);
blinkOnce(); document.documentElement.style.setProperty("--intensity", String(val));
}
// 15% chance auf Doppelt-Blink function setMouthOpen(v) {
if (Math.random() < 0.15) setTimeout(blinkOnce, 220); const val = clamp(Number(v ?? 0), 0, 1);
document.documentElement.style.setProperty("--mouth-open", String(val));
}
loop(); function speak(amount = 0.8, durationMs = 600) {
}, next); 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() { function connect() {
const es = new EventSource("/events"); const es = new EventSource("/events");
es.addEventListener("emotion", (e) => setEmotion(e.data)); es.addEventListener("state", (e) => {
es.onmessage = (e) => setEmotion(e.data); // fallback 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.onerror = () => {
es.close(); es.close();
setTimeout(connect, 1000); setTimeout(connect, 1000);
@ -76,6 +173,22 @@ function connect() {
} }
connect(); connect();
startPupilWander(); startWander();
startBlinking();
/* 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();
})();

View File

@ -0,0 +1,391 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Robot Face Control</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b0f14;
color: #d7e3f2;
}
.wrap {
max-width: 720px;
margin: 0 auto;
padding: 16px;
display: grid;
gap: 14px;
}
.card {
background: rgba(18, 25, 37, 0.85);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px;
padding: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
}
h1 { font-size: 18px; margin: 0 0 10px 0; opacity: 0.95; }
h2 { font-size: 14px; margin: 0 0 10px 0; opacity: 0.85; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
button {
background: #121925;
color: #d7e3f2;
border: 1px solid rgba(255,255,255,0.10);
padding: 12px 10px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
transition: transform 120ms ease, border-color 120ms ease;
user-select: none;
touch-action: manipulation;
}
button:active { transform: scale(0.98); }
button.primary { border-color: rgba(0,255,180,0.35); }
.row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.row3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
input[type="range"] {
width: 100%;
accent-color: #6ee7ff;
}
.value {
font-variant-numeric: tabular-nums;
opacity: 0.85;
min-width: 72px;
text-align: right;
}
.toggle {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.10);
background: #121925;
}
.dot {
width: 10px; height: 10px; border-radius: 999px;
background: rgba(255,255,255,0.35);
}
.dot.ok { background: rgba(0,255,180,0.75); }
.dot.bad { background: rgba(255,70,70,0.75); }
.small { font-size: 12px; opacity: 0.75; }
.footer {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Robot Face Control</h1>
<div class="toggle">
<span class="pill"><span id="connDot" class="dot"></span><span id="connTxt">offline</span></span>
<div class="small" id="stateTxt"></div>
</div>
</div>
<div class="card">
<h2>Emotion</h2>
<div class="grid">
<button data-emotion="neutral" class="primary">neutral</button>
<button data-emotion="happy">happy</button>
<button data-emotion="sad">sad</button>
<button data-emotion="angry">angry</button>
<button data-emotion="surprised">surprised</button>
<button data-emotion="sleepy">sleepy</button>
</div>
<div class="row">
<label for="intensity">Intensität</label>
<div class="value" id="intensityVal">0.70</div>
</div>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.70" />
<div class="footer">
<button id="blinkBtn">Blinzeln</button>
<button id="wanderBtn" class="primary">Blick: wander</button>
</div>
</div>
<div class="card">
<h2>Blick (fix)</h2>
<div class="row">
<label for="lookX">X (links ↔ rechts)</label>
<div class="value" id="lookXVal">0.00</div>
</div>
<input id="lookX" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="row">
<label for="lookY">Y (oben ↕ unten)</label>
<div class="value" id="lookYVal">0.00</div>
</div>
<input id="lookY" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="footer">
<button id="applyLookBtn">Blick anwenden</button>
<button id="centerLookBtn">Zentrieren</button>
</div>
</div>
<div class="card">
<h2>Sprechen</h2>
<div class="footer">
<button id="speakBtn">Speak (700ms)</button>
<button id="talkToggleBtn" class="primary">Talk: OFF</button>
</div>
<div class="row">
<label for="talkRate">Rate (Hz)</label>
<div class="value" id="talkRateVal">3.20</div>
</div>
<input id="talkRate" type="range" min="0.5" max="10" step="0.1" value="3.2" />
<div class="row">
<label for="talkAmount">Mund-Öffnung</label>
<div class="value" id="talkAmountVal">0.90</div>
</div>
<input id="talkAmount" type="range" min="0" max="1" step="0.01" value="0.9" />
<div class="row">
<label for="talkJitter">Jitter</label>
<div class="value" id="talkJitterVal">0.25</div>
</div>
<input id="talkJitter" type="range" min="0" max="1" step="0.01" value="0.25" />
</div>
<div class="card small">
Tipp: URL am Handy öffnen: <b>/control/</b> (z. B. http://raspy/control/)
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const connDot = $("connDot");
const connTxt = $("connTxt");
const stateTxt = $("stateTxt");
const intensity = $("intensity");
const intensityVal = $("intensityVal");
const lookX = $("lookX"), lookXVal = $("lookXVal");
const lookY = $("lookY"), lookYVal = $("lookYVal");
const talkToggleBtn = $("talkToggleBtn");
const talkRate = $("talkRate"), talkRateVal = $("talkRateVal");
const talkAmount = $("talkAmount"), talkAmountVal = $("talkAmountVal");
const talkJitter = $("talkJitter"), talkJitterVal = $("talkJitterVal");
let talkEnabled = false;
let wander = true;
let lastState = null;
function fmt(n, d=2){ return Number(n).toFixed(d); }
async function postState(patch) {
const res = await fetch("/api/state", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error("POST failed: " + res.status);
return res.json();
}
function setConn(ok) {
connDot.classList.toggle("ok", ok);
connDot.classList.toggle("bad", !ok);
connTxt.textContent = ok ? "online" : "offline";
}
function updateStateText(s) {
if (!s) { stateTxt.textContent = ""; return; }
const e = s.emotion ?? "?";
const i = s.intensity ?? "?";
const t = s.talk?.enabled ? "talk" : "silent";
const lk = (s.look === null || s.look === undefined) ? "wander" : `look(${fmt(s.look.x)},${fmt(s.look.y)})`;
stateTxt.textContent = `${e} | intensity ${fmt(i)} | ${t} | ${lk}`;
}
// Emotion buttons
document.querySelectorAll("button[data-emotion]").forEach(btn => {
btn.addEventListener("click", async () => {
try {
await postState({ emotion: btn.dataset.emotion });
} catch(e) { console.error(e); }
});
});
// Intensity slider
intensity.addEventListener("input", () => intensityVal.textContent = fmt(intensity.value));
intensity.addEventListener("change", async () => {
try { await postState({ intensity: Number(intensity.value) }); } catch(e){ console.error(e); }
});
// Blink + Wander toggle
$("blinkBtn").addEventListener("click", async () => {
try { await postState({ blink: true }); } catch(e){ console.error(e); }
});
$("wanderBtn").addEventListener("click", async (ev) => {
wander = !wander;
ev.target.textContent = "Blick: " + (wander ? "wander" : "fix");
ev.target.classList.toggle("primary", wander);
try {
await postState({ look: wander ? null : { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
// Look sliders
function updateLookLabels(){
lookXVal.textContent = fmt(lookX.value);
lookYVal.textContent = fmt(lookY.value);
}
lookX.addEventListener("input", updateLookLabels);
lookY.addEventListener("input", updateLookLabels);
updateLookLabels();
$("applyLookBtn").addEventListener("click", async () => {
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
$("centerLookBtn").addEventListener("click", async () => {
lookX.value = 0; lookY.value = 0; updateLookLabels();
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: 0, y: 0 } });
} catch(e){ console.error(e); }
});
// Speak (one-shot)
$("speakBtn").addEventListener("click", async () => {
try {
await postState({ mouth: { open: true, amount: Number(talkAmount.value), duration_ms: 700 } });
} catch(e){ console.error(e); }
});
// Talk controls
function updateTalkLabels(){
talkRateVal.textContent = fmt(talkRate.value);
talkAmountVal.textContent = fmt(talkAmount.value);
talkJitterVal.textContent = fmt(talkJitter.value);
}
talkRate.addEventListener("input", updateTalkLabels);
talkAmount.addEventListener("input", updateTalkLabels);
talkJitter.addEventListener("input", updateTalkLabels);
updateTalkLabels();
async function pushTalk() {
try {
await postState({
talk: {
enabled: talkEnabled,
rate_hz: Number(talkRate.value),
amount: Number(talkAmount.value),
jitter: Number(talkJitter.value),
}
});
} catch(e){ console.error(e); }
}
talkToggleBtn.addEventListener("click", async () => {
talkEnabled = !talkEnabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled); // OFF = primary (wie vorher)
await pushTalk();
});
[talkRate, talkAmount, talkJitter].forEach(el => {
el.addEventListener("change", async () => {
if (talkEnabled) await pushTalk();
});
});
// Live state via SSE (optional, aber nice)
function connectSSE() {
try {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
setConn(true);
try {
lastState = JSON.parse(e.data);
updateStateText(lastState);
// sync some UI hints
if (typeof lastState?.intensity === "number") {
intensity.value = lastState.intensity;
intensityVal.textContent = fmt(intensity.value);
}
if (lastState?.look === null) {
wander = true;
$("wanderBtn").textContent = "Blick: wander";
$("wanderBtn").classList.add("primary");
} else if (lastState?.look) {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
lookX.value = lastState.look.x ?? 0;
lookY.value = lastState.look.y ?? 0;
updateLookLabels();
}
if (lastState?.talk) {
talkEnabled = !!lastState.talk.enabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled);
if (typeof lastState.talk.rate_hz === "number") talkRate.value = lastState.talk.rate_hz;
if (typeof lastState.talk.amount === "number") talkAmount.value = lastState.talk.amount;
if (typeof lastState.talk.jitter === "number") talkJitter.value = lastState.talk.jitter;
updateTalkLabels();
}
} catch {}
});
es.onerror = () => { setConn(false); es.close(); setTimeout(connectSSE, 1200); };
} catch {
setConn(false);
}
}
connectSSE();
// Initial ping
fetch("/api/state").then(r => r.json()).then(j => {
setConn(true);
lastState = j.state;
updateStateText(lastState);
}).catch(() => setConn(false));
</script>
</body>
</html>

View File

@ -205,9 +205,14 @@ body.emotion-happy {
--omouth: 0; --omouth: 0;
--mouth-line-y: 58%; --mouth-line-y: 58%;
--mouth-line-h: 12px; --mouth-line-h: 12px;
--mouth-line-opacity: 0;
} }
body.emotion-happy .mouth-shape { } body.emotion-happy .mouth-shape { }
body.emotion-happy .mouth-shape.frown { } /* no-op */ body.emotion-happy .mouth-shape.frown { } /* no-op */
body.emotion-happy .mouth-shape::before {
top: -20%; /* war 38% -> kleiner = weiter nach oben */
height: 62%; /* etwas größer, damit der Bogen schön wirkt */
}
body.emotion-sad { body.emotion-sad {
--glow: rgba(120, 180, 255, 0.32); --glow: rgba(120, 180, 255, 0.32);
@ -216,6 +221,7 @@ body.emotion-sad {
--omouth: 0; --omouth: 0;
--mouth-line-y: 42%; --mouth-line-y: 42%;
--mouth-line-h: 12px; --mouth-line-h: 12px;
--mouth-line-opacity: 0;
} }
body.emotion-sad .mouth-shape { } body.emotion-sad .mouth-shape { }
body.emotion-sad .mouth-shape.frown { } /* no-op */ body.emotion-sad .mouth-shape.frown { } /* no-op */
@ -256,6 +262,25 @@ body.emotion-sleepy .eye {
max-height: 90px; max-height: 90px;
} }
/* Smooth transition for everything */ /* Smooth transition for everything */
* { box-sizing: border-box; } * { box-sizing: border-box; }
:root{
--intensity: 0.7; /* 0..1 */
--mouth-open: 0; /* 0..1 */
}
/* Glow stärker je nach Intensität */
.eye, .mouth {
box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow);
}
/* Mund-Linie “öffnet”: wird dicker und etwas tiefer */
.mouth-shape::after {
height: calc(var(--mouth-line-h) + 26px * var(--mouth-open));
top: calc(var(--mouth-line-y) + 6% * var(--mouth-open));
opacity: calc(var(--mouth-line-opacity) + 0.10 * var(--mouth-open));
}