Files
desklock/sim/face/index.html
T
jpmschweitzerandClaude Fable 5 f5df5049db
Test, Build and Push / test-gateway (push) Successful in 1m2s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Add rage table-flip state; wire gateway to live Speaches; add CI pipeline
- 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>
2026-07-14 17:54:30 +02:00

254 lines
9.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>