- Face: new 'rage' state — 3-frame kaomoji loop (stare, flip the table, put it back) for in-flight request failures; 'error' stays the quiet persistent face for a dead link. Sim + artifact + design doc updated. - Gateway: stt.py/tts.py are now pluggable backends. Default 'speaches' talks OpenAI-format HTTP to the live container on :8601 (faster-whisper-small STT, Kokoro bm_george TTS with 24->16 kHz audioop resample); 'embedded' fallback kept behind the [speech] extra. Verified with a live TTS->STT round trip (warm: STT 0.27s, TTS 1.9s). Docker image is now slim (no CUDA/ML deps). Python pinned to 3.12 (system 3.8 too old, audioop gone in 3.13). - CI: .gitea/workflows/build.yml — lint+test on main pushes; on v* tags test, build gateway image, push to registry, release, and trigger Watchtower (tatlock pattern; needs REGISTRY_USER/REGISTRY_PASSWORD/ WATCHTOWER_TOKEN secrets). Runtime stack in deploy/desklock-gateway.yml. - architecture.md: measured speech latencies, deployed-Speaches status, CI & deployment section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
9.7 KiB
HTML
254 lines
9.7 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>DeskLock Face — design simulator</title>
|
||
<!--
|
||
Design reference for the DeskLock face (docs/architecture.md "Face design").
|
||
This file is the source of truth for the look: the LVGL firmware port must match
|
||
the states, glyphs, and rain behaviour defined here.
|
||
|
||
URL params: ?state=idle|listening|pensive|effort|speaking|error
|
||
&demo=1 (auto-cycle) &nochrome=1 (hide controls, device view only)
|
||
-->
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; padding: 24px;
|
||
background: #050807; color: #7fae91;
|
||
font-family: "Courier New", monospace;
|
||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||
min-height: 100vh;
|
||
}
|
||
body.nochrome { padding: 0; }
|
||
body.nochrome #controls, body.nochrome #title { display: none; }
|
||
#title { font-size: 14px; letter-spacing: .3em; color: #2f7a4b; }
|
||
#stage { transform-origin: top center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||
|
||
/* The physical screen: 800x800 round panel */
|
||
#screen {
|
||
width: 800px; height: 800px; border-radius: 50%;
|
||
background: #000; overflow: hidden; position: relative;
|
||
box-shadow: 0 0 0 12px #0d0d0d, 0 0 70px rgba(0, 255, 120, .10);
|
||
}
|
||
#rain { position: absolute; inset: 0; }
|
||
#faceShade {
|
||
position: absolute; inset: 0;
|
||
background: radial-gradient(circle at 50% 47%,
|
||
rgba(0,0,0,.94) 0 25%, rgba(0,0,0,.6) 37%, rgba(0,0,0,0) 54%);
|
||
}
|
||
#faceWrap {
|
||
position: absolute; inset: 0;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
gap: 44px; color: #adffc8; text-shadow: 0 0 24px rgba(0,255,130,.4);
|
||
animation: breathe 4.5s ease-in-out infinite;
|
||
}
|
||
#eyes { font-size: 170px; line-height: 1; margin: 0; }
|
||
#mouth { font-size: 105px; line-height: 1; margin: 0; }
|
||
#thought {
|
||
position: absolute; top: 22%; right: 20%; width: 140px;
|
||
font-size: 62px; color: #63cf8b; text-align: left;
|
||
}
|
||
#clock {
|
||
font-size: 46px; color: #2f7a4b; text-shadow: none;
|
||
letter-spacing: .18em; display: none;
|
||
}
|
||
body[data-state="idle"] #clock { display: block; }
|
||
body[data-state="effort"] #faceWrap { animation: jitter .16s steps(2) infinite; }
|
||
body[data-state="error"] #faceWrap { opacity: .45; animation: none; }
|
||
|
||
/* wait cues while Tatlock works (10-25 s): orbiting arc + elapsed counter */
|
||
#orbit {
|
||
position: absolute; inset: 12px; border-radius: 50%;
|
||
border: 6px solid transparent; border-top-color: #37ff8b;
|
||
filter: drop-shadow(0 0 9px rgba(55,255,139,.55));
|
||
animation: spin 1.4s linear infinite; display: none;
|
||
}
|
||
#elapsed {
|
||
position: absolute; bottom: 13%; left: 50%; transform: translateX(-50%);
|
||
font-size: 42px; color: #2f7a4b; letter-spacing: .15em; display: none;
|
||
background: rgba(0,0,0,.8); padding: 6px 20px; border-radius: 12px;
|
||
}
|
||
body[data-state="effort"] #orbit,
|
||
body[data-state="effort"] #elapsed { display: block; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
body[data-state="rage"] #eyes { font-size: 70px; }
|
||
body[data-state="rage"] #mouth { display: none; }
|
||
|
||
@keyframes breathe { 0%,100% { transform: translateY(0); } 50% { transform: translateY(9px); } }
|
||
@keyframes jitter { 0% { transform: translate(1px,-1px); } 100% { transform: translate(-1px,1px); } }
|
||
|
||
#controls { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||
#controls button {
|
||
background: #0c1410; border: 1px solid #1d3527; color: #7fae91;
|
||
font-family: inherit; font-size: 14px; padding: 8px 16px; cursor: pointer;
|
||
letter-spacing: .1em;
|
||
}
|
||
#controls button.active { border-color: #37ff8b; color: #b9ffd6; }
|
||
</style>
|
||
</head>
|
||
<body data-state="idle">
|
||
<div id="title">DESKLOCK · FACE SIMULATOR · 800×800</div>
|
||
<div id="stage">
|
||
<div id="screen">
|
||
<canvas id="rain" width="800" height="800"></canvas>
|
||
<div id="faceShade"></div>
|
||
<div id="faceWrap">
|
||
<pre id="eyes"></pre>
|
||
<pre id="mouth"></pre>
|
||
<div id="clock"></div>
|
||
</div>
|
||
<div id="thought"></div>
|
||
<div id="orbit"></div>
|
||
<div id="elapsed"></div>
|
||
</div>
|
||
<div id="controls"></div>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
/* ---- expression + rain definitions (the contract for the LVGL port) ---- */
|
||
const STATES = {
|
||
idle: { eyes: "- -", mouth: "\\_/", blink: true, rain: { streams: 2, speed: 4 } },
|
||
listening: { eyes: "O O", mouth: "o", blink: true, rain: { streams: 16, speed: 7 } },
|
||
pensive: { eyes: "· ·", mouth: "~", blink: false, thought: true,
|
||
rain: { streams: 7, speed: 5 } },
|
||
effort: { eyes: "> <", mouth: "~", blink: false, rain: { streams: 40, speed: 16 } },
|
||
speaking: { eyes: "^ ^", mouth: "o", blink: true, talk: true,
|
||
rain: { streams: 14, speed: 9 } },
|
||
rage: { mouth: "", blink: false, rain: { streams: 34, speed: 20 },
|
||
frames: [ { t: "(°□°) ┬─┬", ms: 900 },
|
||
{ t: "(╯°□°)╯︵ ┻━┻", ms: 1300 },
|
||
{ t: "┬─┬ ノ( º_º ノ)", ms: 1400 } ] },
|
||
error: { eyes: "x x", mouth: "-", blink: false, rain: { streams: 0, speed: 0 } },
|
||
};
|
||
const ORDER = ["idle", "listening", "pensive", "effort", "speaking", "rage", "error"];
|
||
const TALK = ["o", "O", "-", "O", "=", "o"];
|
||
const GLYPHS = "アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ACEFHKZ$#%*+=<>";
|
||
|
||
const qs = new URLSearchParams(location.search);
|
||
let cur = ORDER.includes(qs.get("state")) ? qs.get("state") : "idle";
|
||
const st = () => STATES[cur];
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
const eyesEl = el("eyes"), mouthEl = el("mouth"), thoughtEl = el("thought"),
|
||
clockEl = el("clock"), elapsedEl = el("elapsed");
|
||
let stateSince = performance.now();
|
||
|
||
/* ---- matrix rain ---- */
|
||
const R = 800, CELL = 22, COLS = Math.floor(R / CELL);
|
||
const ctx = el("rain").getContext("2d");
|
||
ctx.textAlign = "center";
|
||
let streams = [];
|
||
const spawn = () => ({
|
||
col: Math.floor(Math.random() * COLS),
|
||
y: -Math.random() * 14,
|
||
speed: st().rain.speed * (0.7 + Math.random() * 0.6),
|
||
});
|
||
let last = performance.now();
|
||
function frame(now) {
|
||
const dt = Math.min(0.05, (now - last) / 1000); last = now;
|
||
ctx.fillStyle = "rgba(0,0,0,0.08)";
|
||
ctx.fillRect(0, 0, R, R);
|
||
ctx.font = "bold " + CELL + "px monospace";
|
||
for (const s of streams) {
|
||
s.y += s.speed * dt;
|
||
const px = s.col * CELL + CELL / 2, py = s.y * CELL;
|
||
ctx.fillStyle = "rgba(195,255,215,0.95)";
|
||
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], px, py);
|
||
ctx.fillStyle = "rgba(0,230,100,0.55)";
|
||
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], px, py - CELL);
|
||
}
|
||
streams = streams.filter((s) => s.y * CELL <= R + 2 * CELL);
|
||
while (streams.length < st().rain.streams) streams.push(spawn());
|
||
}
|
||
|
||
/* ---- face behaviour ---- */
|
||
function applyFace() {
|
||
eyesEl.textContent = st().eyes || "";
|
||
mouthEl.textContent = st().mouth;
|
||
thoughtEl.textContent = "";
|
||
document.body.dataset.state = cur;
|
||
document.querySelectorAll("#controls button[data-state]").forEach(
|
||
(b) => b.classList.toggle("active", b.dataset.state === cur));
|
||
playFrames();
|
||
}
|
||
|
||
/* whole-line kaomoji sequences (rage) render through the eyes slot */
|
||
let animGen = 0;
|
||
function playFrames() {
|
||
const gen = ++animGen;
|
||
const frames = st().frames;
|
||
if (!frames) return;
|
||
let i = 0;
|
||
(function step() {
|
||
if (gen !== animGen) return;
|
||
eyesEl.textContent = frames[i % frames.length].t;
|
||
setTimeout(step, frames[i++ % frames.length].ms);
|
||
})();
|
||
}
|
||
function setState(name) { cur = name; stateSince = performance.now(); applyFace(); }
|
||
|
||
setInterval(() => {
|
||
elapsedEl.textContent = "[ " + Math.floor((performance.now() - stateSince) / 1000) + "s ]";
|
||
}, 250);
|
||
|
||
(function blinkLoop() {
|
||
setTimeout(() => {
|
||
if (st().blink) {
|
||
eyesEl.textContent = st().eyes.replace(/[^ ]/g, "_");
|
||
setTimeout(() => { eyesEl.textContent = st().eyes; }, 130);
|
||
}
|
||
blinkLoop();
|
||
}, 2600 + Math.random() * 3600);
|
||
})();
|
||
|
||
let talkI = 0;
|
||
setInterval(() => { if (st().talk) mouthEl.textContent = TALK[talkI++ % TALK.length]; }, 150);
|
||
|
||
let dotI = 0;
|
||
setInterval(() => {
|
||
thoughtEl.textContent = st().thought ? ".".repeat(1 + (dotI++ % 3)) : "";
|
||
}, 480);
|
||
|
||
setInterval(() => {
|
||
const d = new Date();
|
||
clockEl.textContent =
|
||
String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
||
}, 1000);
|
||
|
||
/* ---- sim chrome ---- */
|
||
const controls = el("controls");
|
||
for (const name of ORDER) {
|
||
const b = document.createElement("button");
|
||
b.textContent = name; b.dataset.state = name;
|
||
b.onclick = () => { demo = false; demoBtn.textContent = "demo"; setState(name); };
|
||
controls.appendChild(b);
|
||
}
|
||
let demo = qs.get("demo") === "1", demoI = 0;
|
||
const demoBtn = document.createElement("button");
|
||
demoBtn.textContent = demo ? "demo: on" : "demo";
|
||
demoBtn.onclick = () => { demo = !demo; demoBtn.textContent = demo ? "demo: on" : "demo"; };
|
||
controls.appendChild(demoBtn);
|
||
setInterval(() => { if (demo) setState(ORDER[++demoI % ORDER.length]); }, 3000);
|
||
|
||
if (qs.get("nochrome") === "1") document.body.classList.add("nochrome");
|
||
function rescale() {
|
||
const pad = document.body.classList.contains("nochrome") ? 0 : 140;
|
||
const s = Math.min(1, (innerWidth - 20) / 824, (innerHeight - pad) / 824);
|
||
el("stage").style.transform = "scale(" + s + ")";
|
||
}
|
||
addEventListener("resize", rescale);
|
||
|
||
rescale();
|
||
applyFace();
|
||
/* fixed-step timer, not requestAnimationFrame: mirrors the LVGL port (lv_timer)
|
||
and renders under headless/virtual-time captures where rAF is starved */
|
||
setInterval(() => frame(performance.now()), 33);
|
||
</script>
|
||
</body>
|
||
</html>
|