Add face design: ASCII expressions + matrix rain simulator, wait cues, fresh latency numbers

- sim/face/index.html: browser simulator of the 800x800 round panel with
  six states (idle/listening/pensive/effort/speaking/error), state-driven
  rain density, blink/talk/thought animations, idle clock
- effort state gets hard-required wait cues: orbiting bezel arc, elapsed
  counter, max rain (Tatlock turns run 10-25s)
- rain driven by setInterval, not rAF: renders under the screenshot
  tool's --virtual-time-budget and mirrors LVGL lv_timer
- architecture.md: Face design contract (state table, protocol mapping,
  LVGL port notes) + latency table updated to GPU-era benchmarks
  (Steward ~6s warm, full flow 11-25s; old CPU-era figures retired)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:32:16 +02:00
co-authored by Claude Fable 5
parent 0886c84d5e
commit 42b988b7ef
4 changed files with 299 additions and 15 deletions
+8
View File
@@ -22,6 +22,14 @@ The device and gateway speak a WebSocket protocol defined in `docs/architecture.
**That doc is the contract** — update it in the same change as any protocol edit on
either side.
The face (black screen, ASCII glyph expressions, matrix rain as activity signal) is
designed in `sim/face/index.html` — the design source of truth — and specified in the
"Face design" section of `docs/architecture.md`. Change the sim and the doc together;
the LVGL implementation follows them. Verify sim changes visually with
`~/bin/claude-screenshot` (note: the tool uses `--virtual-time-budget`, which starves
`requestAnimationFrame` — drive sim animation with `setInterval`, which also mirrors
LVGL timers).
## Hard rules
- **Keep the firmware thin.** No STT, no TTS, no conversation logic on the device.
+3
View File
@@ -54,6 +54,9 @@ See [docs/architecture.md](docs/architecture.md) for the full design.
- `firmware/` — ESP-IDF (C, LVGL 9) application for the ESP32-P4
- `gateway/` — Python FastAPI voice gateway, deployed as a container on tower-of-joy
- `sim/face/` — browser simulator of the face (design source of truth; serve with
`python3 -m http.server` and open `index.html`, or use `?state=…&nochrome=1` for
screenshots)
- `docs/` — architecture and design notes
## Roadmap
+57 -15
View File
@@ -39,12 +39,8 @@ the **speech layer** (Speaches) owns the actual STT/TTS models on the GPU.
Responsibilities:
- **Face rendering** (LVGL 9 on the 800×800 round MIPI-DSI panel via the
`waveshare/esp32_p4_wifi6_touch_lcd_xc` BSP). Face states:
- `idle` — subtle animation + clock (it's a desk clock when nobody's talking to it)
- `listening` — visual feedback that the mic is hot
- `thinking` — Tatlock is working on a reply (this state earns its keep; see
[Latency budget](#latency-budget--streaming))
- `speaking` — mouth/waveform animation synced to TTS playback
`waveshare/esp32_p4_wifi6_touch_lcd_xc` BSP). Six expression states — see
[Face design](#face-design) for the visual contract.
- **Audio capture**: dual mics through the ES7210 (hardware echo cancellation reference
from the playback path), 16 kHz 16-bit mono PCM.
- **Audio playback**: ES8311 codec → speaker. Plays PCM streamed from the gateway.
@@ -123,27 +119,73 @@ Untouched by this project. DeskLock consumes its OpenAI-compatible API over the
LAN (port 8000, bypassing the Authentik-protected public route). If device auth is needed
later, the gateway holds the credential — never the firmware.
## Face design
**Aesthetic**: pure black screen; a face drawn from ASCII/terminal glyphs in green
phosphor (`#adffc8` face, dimmer greens for secondary info); Matrix-style digital rain
whose **density encodes activity** — barely-there drips when idle, a downpour while
Tatlock works. No bitmaps, no skeuomorphism: glyphs only.
**Source of truth**: `sim/face/index.html` — a self-contained browser simulator of the
800×800 round panel. Design changes land there first, get approved visually, then get
ported to LVGL. The `STATES` table in the sim defines the contract:
| State | Eyes | Mouth | Rain | Extra cues |
|-------|------|-------|------|------------|
| `idle` | `- -` | `\_/` | 2 slow streams | clock (HH:MM), breathing bob, blinks |
| `listening` | `O O` | `o` | 16 streams | blinks |
| `pensive` | `· ·` | `~` | 7 streams | cycling `...` thought dots |
| `effort` | `> <` | `~` | 40 fast streams | **orbit arc on bezel + `[ Ns ]` elapsed counter**, face jitter |
| `speaking` | `^ ^` | cycles `o O - O = o` | 14 streams | mouth animates ~150 ms/frame |
| `error` | `x x` | `-` | none (rain dies) | face dims to 45% |
**Wait cues are a hard requirement** (user-stated): Tatlock turns take 1025 s, so
`effort` must always show *alive-and-working* signals — the orbiting bezel arc, the
elapsed-seconds counter, and max rain. Never a bare static face during a wait, and no
fake progress bars — only honest cues.
**Protocol → face mapping**: gateway `state: thinking``effort`; transcription and
other short local waits → `pensive`; `listening`/`speaking` map 1:1; WebSocket
disconnected → `error`; otherwise `idle`.
**LVGL port notes** (for phase 2):
- Drive everything from fixed-step `lv_timer`s (~30 fps rain tick) — the sim
deliberately uses `setInterval`, not `requestAnimationFrame`, to mirror this.
- Rain: `lv_canvas` (or a pooled label grid) with per-frame fade; orbit arc = `lv_arc`.
- Fonts: generate a large monospace glyph font including the katakana subset used in
`GLYPHS` via `lv_font_conv`; the built-in `unscii` fonts are too small for 800 px.
- The sim's text glow (`text-shadow`) is browser flair — the device renders flat glyphs.
## Latency budget & streaming
Measured/known numbers that shape the design:
Measured/known numbers that shape the design (Tatlock figures per tatlock CLAUDE.md,
GPU-resident benchmarks of 2026-07-14, gemma4:e2b at ~100 tok/s):
| Stage | Cost |
|-------|------|
| STT (whisper `small`, GPU) | a few hundred ms for a ~5 s utterance |
| TTS (Piper/Kokoro) | faster than realtime |
| **Tatlock, full local flow (gemma4)** | **~35 s Steward analysis warm; ~2 min end-to-end** (per tatlock CLAUDE.md) |
| Tatlock Steward analysis | ~6 s warm |
| **Tatlock, full local flow** | **1125 s end-to-end** (librarian-routed ~2025 s) |
| Tatlock cold start (>2 h idle) | +~8 s (`OLLAMA_KEEP_ALIVE=2h`) |
Speech is not the bottleneck — **Tatlock is**, by two orders of magnitude. Constraints
this imposes:
(Older "~35 s Steward / ~2 min flow" figures were from a CPU-only driver-mismatch era —
do not plan against them.)
Speech is not the bottleneck — **Tatlock is**, by one to two orders of magnitude.
Constraints this imposes:
1. **The gateway must consume Tatlock's streaming response and synthesize
sentence-by-sentence**, forwarding audio as each sentence is ready. The device starts
speaking after the first sentence instead of waiting for the full reply. The WS
protocol already supports this: one `audio_start` … PCM … `audio_end` envelope with
chunks arriving as they're synthesized — the device just plays a continuous stream.
speaking after the first sentence instead of waiting for the full reply — with
streaming, first audio should land roughly at Steward-time + first-sentence-time,
well under the 1125 s full-flow figure. The WS protocol already supports this: one
`audio_start` … PCM … `audio_end` envelope with chunks arriving as they're
synthesized — the device just plays a continuous stream.
2. **The `thinking` face state is a first-class feature**, not decoration — it's what
makes a long Tatlock turn feel intentional instead of broken. Consider progress cues
(e.g. surface Tatlock's reasoning summaries on-screen) later.
makes a 1025 s Tatlock turn feel intentional instead of broken. Consider progress
cues (e.g. surface Tatlock's reasoning summaries on-screen) later.
3. A **fast lane** may eventually be needed: MultiNet on-device commands for instant
home-automation phrases, and/or a low-latency intent path in Tatlock itself. Out of
scope for now, but don't design it out.
+231
View File
@@ -0,0 +1,231 @@
<!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); } }
@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 } },
error: { eyes: "x x", mouth: "-", blink: false, rain: { streams: 0, speed: 0 } },
};
const ORDER = ["idle", "listening", "pensive", "effort", "speaking", "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));
}
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>