diff --git a/face/var/www/html/app.js b/face/var/www/html/app.js deleted file mode 100755 index 4a112cc..0000000 --- a/face/var/www/html/app.js +++ /dev/null @@ -1,194 +0,0 @@ -const label = document.getElementById("label"); -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") - .toLowerCase() - .replace(/[^a-z0-9_-]/g, ""); - - current = safe; - document.body.className = `emotion-${safe}`; - label.textContent = safe; - - // surprised -> O-mouth - if (safe === "surprised") document.body.classList.add("has-omouth"); - else document.body.classList.remove("has-omouth"); - - // frown for sad/angry - if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown"); - else mouthShape.classList.remove("frown"); -} - -function blinkOnce() { - eyes.forEach(e => e.classList.add("blink")); - setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120); -} - -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`); -} - -function setIntensity(v) { - const val = clamp(Number(v ?? 0.7), 0, 1); - document.documentElement.style.setProperty("--intensity", String(val)); -} - -function setMouthOpen(v) { - const val = clamp(Number(v ?? 0), 0, 1); - document.documentElement.style.setProperty("--mouth-open", String(val)); -} - -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), - }; - - 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("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); - }; -} - -connect(); -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/face.js b/face/var/www/html/face.js new file mode 100644 index 0000000..d929b1d --- /dev/null +++ b/face/var/www/html/face.js @@ -0,0 +1,312 @@ +// 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 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(); +})(); + diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html index bacabd8..53759f8 100755 --- a/face/var/www/html/index.html +++ b/face/var/www/html/index.html @@ -2,27 +2,271 @@
- -