Files
hermes-bot/docs/face-prototype.html
T
kyleandClaude Sonnet 5.5 4bd4729b46 Initial commit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:28:01 -07:00

790 lines
43 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.
<title>Hermes Scope Face</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&family=Saira+Condensed:wght@500;600;700&display=swap">
<style>
/* Layout: an instrument front panel. The CRT (the robot's 240x240 display) on the left, labelled controls on the right; stacks on phones. */
:root {
--panel: #d6dce2; /* cool instrument-grey front panel */
--panel-2: #c5cdd5; /* recessed control wells */
--ink: #1b2229;
--ink-2: #4d5965;
--rule: #a7b1bb;
--accent: #285a87; /* panel-print blue */
--lamp: #e8961e; /* indicator lamp amber */
--lamp-off: #8e979f;
--bezel: #23282e;
--bezel-edge: #3a4149;
--screen: #000000;
--focus: #285a87;
--font-label: "Saira Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
--font-body: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
color-scheme: dark;
}
body { background: var(--panel); color: var(--ink); font-family: var(--font-body); font-size: 15px; line-height: 1.5; }
.wrap { max-width: 1060px; margin: 0 auto; padding-inline: 20px; padding-block: 28px 40px; display: grid; gap: 28px; }
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 20px; border-bottom: 2px solid var(--ink); padding-bottom: 10px; }
h1 { margin: 0; font-family: var(--font-label); font-weight: 700; font-size: clamp(26px, 4vw, 34px); letter-spacing: .04em; text-transform: uppercase; line-height: 1.05; text-wrap: balance; }
h1 span { color: var(--accent); }
.model { font-family: var(--font-label); font-weight: 500; font-size: 14px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
.bench { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); gap: 28px; align-items: start; }
@media (max-width: 820px) { .bench { grid-template-columns: minmax(0, 1fr); } }
/* CRT */
.crt { background: var(--bezel); border-radius: 14px; padding: 20px 20px 14px; box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 1px 0 var(--bezel-edge); display: grid; gap: 10px; }
.screen { position: relative; width: 100%; max-width: 480px; aspect-ratio: 1 / 1; margin: 0 auto; background: var(--screen); border-radius: 4px; overflow: hidden; box-shadow: 0 0 0 2px #000, inset 0 0 40px rgba(0,0,0,.9); }
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.screen.pixels canvas#face { image-rendering: pixelated; }
#grat { pointer-events: none; }
.crt-foot { display: flex; justify-content: space-between; gap: 12px; font-family: var(--font-label); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #8b96a1; }
.crt-foot b { font-weight: 600; color: #c3ccd4; }
/* Control panel */
.controls { display: grid; gap: 22px; min-width: 0; }
.group { display: grid; gap: 10px; }
.group-h { display: flex; align-items: center; gap: 10px; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
.group-h::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
.states { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
@media (max-width: 460px) { .states { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.key { appearance: none; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 10px 6px 8px; display: grid; justify-items: center; gap: 6px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; box-shadow: 0 2px 0 var(--rule); transition: transform .06s, box-shadow .06s; }
.key:active { transform: translateY(2px); box-shadow: 0 0 0 var(--rule); }
.key .lamp { width: 9px; height: 9px; border-radius: 50%; background: var(--lamp-off); }
.key[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 8px var(--lamp); }
.key .num { font-family: var(--font-mono); font-size: 11px; font-weight: 400; letter-spacing: 0; color: var(--ink-2); }
.key:focus-visible, .seg button:focus-visible, input:focus-visible, .switch:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
.switch { appearance: none; display: inline-flex; align-items: center; gap: 10px; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 7px 12px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; }
.switch .lamp { width: 8px; height: 8px; border-radius: 50%; background: var(--lamp-off); }
.switch[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 7px var(--lamp); }
.seg { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; background: var(--panel-2); }
.seg button { appearance: none; border: 0; border-right: 1px solid var(--rule); background: transparent; color: var(--ink-2); padding: 7px 11px; cursor: pointer; font-family: var(--font-mono); font-size: 13px; }
.seg button:last-child { border-right: 0; }
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--panel); }
.seg-label { font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); min-width: 64px; }
.knobs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 22px; }
@media (max-width: 460px) { .knobs { grid-template-columns: minmax(0, 1fr); } }
.knob { display: grid; gap: 4px; min-width: 0; }
.knob label { display: flex; justify-content: space-between; gap: 8px; font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
.knob output { font-family: var(--font-mono); font-weight: 500; font-size: 12px; letter-spacing: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
input[type="range"] { width: 100%; accent-color: var(--accent); margin: 0; }
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; flex-wrap: wrap; gap: 4px 18px; }
.readout b { color: var(--ink); font-weight: 500; }
/* Notes */
.notes { display: grid; gap: 12px; }
.map { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 22px; margin: 0; max-width: 78ch; }
.map dt { font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink); padding-top: 1px; }
.map dd { margin: 0; color: var(--ink-2); }
.map code, .notes code { font-family: var(--font-mono); font-size: 13px; color: var(--ink); }
.hint { color: var(--ink-2); font-size: 13px; margin: 0; }
kbd { font-family: var(--font-mono); font-size: 12px; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; color: var(--ink); background: var(--panel-2); }
</style>
<div class="wrap">
<header>
<h1>Hermes <span>Scope</span> Face</h1>
<div class="model">Display prototype · BrainCraft 1.54″ TFT</div>
</header>
<div class="bench">
<div class="crt">
<div class="screen pixels" id="screen">
<canvas id="face" width="240" height="240" aria-label="Animated oscilloscope face"></canvas>
<canvas id="grat" width="480" height="480" aria-hidden="true"></canvas>
</div>
<div class="crt-foot"><span>240 × 240 IPS</span><span>State <b id="stateLabel">Idle</b></span></div>
</div>
<div class="controls">
<section class="group" aria-labelledby="g-face">
<div class="group-h" id="g-face">Face</div>
<div class="row">
<span class="seg-label">Shape</span>
<div class="seg" id="modeSeg" role="group" aria-label="Face shape">
<button id="mode-flat" data-mode="flat" aria-pressed="false">Flat</button>
<button id="mode-head" data-mode="head" aria-pressed="true">Head</button>
<button id="mode-lowpoly" data-mode="lowpoly" aria-pressed="false">Low-poly</button>
</div>
</div>
<div class="row">
<span class="seg-label">Render</span>
<div class="seg" id="styleSeg" role="group" aria-label="Render style">
<button id="style-phosphor" data-style="phosphor" aria-pressed="true">Phosphor</button>
<button id="style-vector" data-style="vector" aria-pressed="false">Vector</button>
</div>
</div>
<p class="hint" id="modeNote">Wireframe head that turns with the servos.</p>
<div class="knobs" id="meshKnobs">
<div class="knob"><label for="mesh">Wireframe <output id="meshOut">45%</output></label><input id="mesh" type="range" min="0" max="100" value="45"></div>
<div class="knob"><label for="density">Mesh lines <output id="densityOut">13 × 15</output></label><input id="density" type="range" min="6" max="20" value="13"></div>
</div>
</section>
<section class="group" aria-labelledby="g-state">
<div class="group-h" id="g-state">Agent state</div>
<div class="states" id="states">
<button class="key" id="k-idle" data-state="idle" aria-pressed="true"><span class="lamp"></span>Idle<span class="num">1</span></button>
<button class="key" id="k-listen" data-state="listening" aria-pressed="false"><span class="lamp"></span>Listen<span class="num">2</span></button>
<button class="key" id="k-think" data-state="thinking" aria-pressed="false"><span class="lamp"></span>Think<span class="num">3</span></button>
<button class="key" id="k-look" data-state="looking" aria-pressed="false"><span class="lamp"></span>Look<span class="num">4</span></button>
<button class="key" id="k-speak" data-state="speaking" aria-pressed="false"><span class="lamp"></span>Speak<span class="num">5</span></button>
</div>
<div class="row">
<button class="switch" id="seqBtn" aria-pressed="true"><span class="lamp"></span>Demo sequence</button>
<span class="hint">Keys <kbd>1</kbd>–<kbd>5</kbd> pick a state, <kbd>Space</kbd> toggles the sequence, <kbd>F</kbd> cycles shape, <kbd>V</kbd> switches render.</span>
</div>
</section>
<section class="group" aria-labelledby="g-crt">
<div class="group-h" id="g-crt">Phosphor</div>
<div class="row">
<div class="seg" id="phosSeg" role="group" aria-label="Phosphor type">
<button id="ph-p31" data-p="P31" aria-pressed="true">P31</button>
<button id="ph-p1" data-p="P1" aria-pressed="false">P1</button>
<button id="ph-p7" data-p="P7" aria-pressed="false">P7</button>
<button id="ph-p3" data-p="P3" aria-pressed="false">P3</button>
</div>
<span class="hint" id="phosNote">P31: bright green, the usual scope tube.</span>
</div>
<p class="hint" id="vectorNote" hidden>Vector render draws clean, even lines with no afterglow, so persistence, focus and beam noise have no effect.</p>
<div class="knobs">
<div class="knob"><label for="persist">Persistence <output id="persistOut">70 ms</output></label><input id="persist" type="range" min="12" max="300" value="70"></div>
<div class="knob"><label for="inten">Intensity <output id="intenOut">1.0×</output></label><input id="inten" type="range" min="30" max="250" value="100"></div>
<div class="knob"><label for="focus">Focus <output id="focusOut">sharp</output></label><input id="focus" type="range" min="0" max="100" value="35"></div>
<div class="knob"><label for="jitter">Beam noise <output id="jitterOut">0.3 px</output></label><input id="jitter" type="range" min="0" max="20" value="3"></div>
</div>
</section>
<section class="group" aria-labelledby="g-head">
<div class="group-h" id="g-head">Head position</div>
<div class="knobs">
<div class="knob"><label for="pan">Pan <output id="panOut">+0°</output></label><input id="pan" type="range" min="-80" max="80" value="0"></div>
<div class="knob"><label for="tilt">Tilt <output id="tiltOut">+0°</output></label><input id="tilt" type="range" min="-45" max="45" value="0"></div>
</div>
</section>
<section class="group" aria-labelledby="g-disp">
<div class="group-h" id="g-disp">Display</div>
<div class="row">
<span class="seg-label">Refresh</span>
<div class="seg" id="fpsSeg" role="group" aria-label="Frame rate">
<button id="fps-60" data-fps="60" aria-pressed="false">60</button>
<button id="fps-30" data-fps="30" aria-pressed="true">30</button>
<button id="fps-20" data-fps="20" aria-pressed="false">20</button>
</div>
<button class="switch" id="pixBtn" aria-pressed="true"><span class="lamp"></span>Show pixels</button>
<button class="switch" id="gratBtn" aria-pressed="false"><span class="lamp"></span>Graticule</button>
</div>
<div class="readout"><span>Beam <b id="roPts">0</b> pts/frame</span><span>Render <b id="roFps">0</b> fps</span><span>Decay <b id="roDecay">0.00</b>/frame</span></div>
</section>
</div>
</div>
<section class="notes" aria-labelledby="g-map">
<div class="group-h" id="g-map">Where each state comes from on the robot</div>
<dl class="map">
<dt>Speak</dt><dd>The mouth is the waveform going to the robot's own speaker, read from the PipeWire output on the Pi. Here it is synthesized speech.</dd>
<dt>Listen</dt><dd>The microphone signal on the Pi, drawn dimmer. Here it is a second synthetic voice at a higher pitch.</dd>
<dt>Look</dt><dd>Triggered inside <code>look()</code> on the Pi: a shutter flash and a squint before the photo.</dd>
<dt>Think</dt><dd>Needs Hermes to say so: a turn-started or tool event sent to a small listener on the Pi. The eyes become rotating 3:2 Lissajous figures.</dd>
<dt>Shapes</dt><dd>Flat is the simplest to read across a room. Head and Low-poly trade some of that for showing direction: Head is a smooth contoured mesh, Low-poly a coarse faceted one. Phosphor render draws them as a scope would, with afterglow and beam-speed brightness; Vector render draws clean, even lines with no trails.</dd>
<dt>Head</dt><dd>In the head shapes the whole wireframe turns with the pan and tilt servos, so the face looks where the camera looks; in flat mode only the eyes shift. The head also cocks while listening and glances up while thinking.</dd>
</dl>
<p class="hint">The renderer is the same math the Pi would run: a 240 × 240 brightness buffer that decays each frame, a beam path traced at constant speed so slow parts burn brighter, and a small blur for glow. It ports to NumPy and Pillow almost line for line.</p>
</section>
</div>
<script>
(() => {
const W = 240, H = 240, N = W * H;
const canvas = document.getElementById('face');
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
const buf = new Float32Array(N); // phosphor energy
const tmp = new Float32Array(N);
const glow = new Float32Array(N);
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------------------------------------------------------------- settings
const PHOSPHORS = {
P31: { rgb: [0.30, 1.00, 0.45], note: 'P31: bright green, the usual scope tube.' },
P1: { rgb: [0.45, 1.00, 0.25], note: 'P1: yellower green, older and longer-glowing tubes.' },
P7: { rgb: [0.62, 0.80, 1.00], note: 'P7: blue-white flash with a long yellow afterglow; radar screens.' },
P3: { rgb: [1.00, 0.68, 0.18], note: 'P3: amber, as on old terminals.' },
};
const S = {
phos: 'P31', persistMs: 70, intensity: 1.0, focus: 0.35, jitter: 0.3,
pan: 0, tilt: 0, fps: 30, pixels: true, graticule: false, sequence: !reduceMotion,
mode: 'head', style: 'phosphor', mesh: 0.45, density: 13,
};
// ---------------------------------------------------------------- state machine
const STATES = ['idle', 'listening', 'thinking', 'looking', 'speaking'];
const LABEL = { idle: 'Idle', listening: 'Listening', thinking: 'Thinking', looking: 'Looking', speaking: 'Speaking' };
const SEQ = [['idle', 3.5], ['listening', 3.6], ['thinking', 2.4], ['looking', 1.3], ['thinking', 1.4], ['speaking', 6.5]];
let state = 'idle', stateT0 = 0, seqIdx = 0, seqT0 = 0;
let lookFlashPending = false;
function setState(s, now) {
if (s === state && s !== 'looking') return;
state = s; stateT0 = now;
if (s === 'looking') lookFlashPending = true;
document.getElementById('stateLabel').textContent = LABEL[s];
document.querySelectorAll('#states .key').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.state === s)));
}
// smoothed animation parameters
const A = { think: 0, open: 1, eyeScale: 1, mouthSpeak: 0, mouthListen: 0, smile: 1, panX: 0, tiltY: 0, yaw: 0, pitch: 0, roll: 0 };
const approach = (cur, target, rate, dt) => cur + (target - cur) * (1 - Math.exp(-rate * dt));
// blink scheduler
let nextBlink = 2.5, blinkT = -1;
// ---------------------------------------------------------------- synthetic voices
function hash(i, seed) { let x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453; return x - Math.floor(x); }
const VOWELS = [[730, 1090], [270, 2290], [300, 870], [530, 1840], [570, 840], [660, 1720]];
function voice(T, seed, baseF0, rate) {
const u = T * rate, i = Math.floor(u), frac = u - i;
const pause = hash(i, seed) < 0.16;
const amp = pause ? 0 : 0.55 + 0.45 * hash(i + 7, seed);
const env = amp * Math.pow(Math.sin(Math.PI * frac), 0.7);
const v0 = VOWELS[Math.floor(hash(i, seed + 3) * VOWELS.length)];
const v1 = VOWELS[Math.floor(hash(i + 1, seed + 3) * VOWELS.length)];
const m = frac * frac * (3 - 2 * frac);
const F1 = v0[0] + (v1[0] - v0[0]) * m, F2 = v0[1] + (v1[1] - v0[1]) * m;
const f0 = baseF0 * (1 + 0.12 * Math.sin(T * 1.7 + seed) + 0.06 * Math.sin(T * 4.3));
const w = [];
let norm = 0;
for (let k = 1; k <= 9; k++) {
const f = k * f0;
const d1 = (f - F1) / 160, d2 = (f - F2) / 260;
const a = Math.exp(-d1 * d1) + 0.65 * Math.exp(-d2 * d2) + (k === 1 ? 0.35 : 0.04);
w.push(a); norm += a;
}
const noise = frac < 0.14 && !pause ? (0.14 - frac) * 3.2 : 0;
return { env, f0, w, norm, noise };
}
// value of the voice at local time t (seconds) inside a trigger-locked window
function voiceSample(v, t, phase) {
let s = 0;
for (let k = 0; k < v.w.length; k++) s += v.w[k] * Math.sin(2 * Math.PI * (k + 1) * v.f0 * t + phase * (k + 1));
return s / v.norm;
}
// ---------------------------------------------------------------- beam deposition
let ptsThisFrame = 0;
function deposit(x, y, e) {
if (S.jitter > 0 && S.style === 'phosphor') { x += (Math.random() - 0.5) * 2 * S.jitter; y += (Math.random() - 0.5) * 2 * S.jitter; }
const xi = Math.floor(x), yi = Math.floor(y);
if (xi < 0 || yi < 0 || xi >= W - 1 || yi >= H - 1) return;
const fx = x - xi, fy = y - yi, i = yi * W + xi;
buf[i] += e * (1 - fx) * (1 - fy);
buf[i + 1] += e * fx * (1 - fy);
buf[i + W] += e * (1 - fx) * fy;
buf[i + W + 1] += e * fx * fy;
ptsThisFrame++;
}
// trace a parametric stroke at constant beam time: n samples, energy e each
function stroke(fn, n, e) {
let px = null, py = null;
for (let j = 0; j <= n; j++) {
const p = fn(j / n);
// vector render keeps line weight even: fill any gap a fast-moving stretch leaves
if (S.style === 'vector' && px !== null) {
const gaps = Math.floor(Math.hypot(p[0] - px, p[1] - py) / 0.7);
for (let g = 1; g <= gaps; g++) { const t = g / (gaps + 1); deposit(px + (p[0] - px) * t, py + (p[1] - py) * t, e); }
}
deposit(p[0], p[1], e); px = p[0]; py = p[1];
}
}
// fast retrace between strokes: few samples, faint streak
let beamX = 120, beamY = 120;
function jumpTo(x, y, e) {
if (S.style === 'phosphor') { // a vector display blanks the beam on retrace
const n = Math.max(4, Math.round(Math.hypot(x - beamX, y - beamY) / 1.5));
for (let j = 1; j <= n; j++) deposit(beamX + (x - beamX) * j / n, beamY + (y - beamY) * j / n, e * 0.045);
}
beamX = x; beamY = y;
}
function trace(fn, n, e) { const p0 = fn(0); jumpTo(p0[0], p0[1], e); stroke(fn, n, e); const p1 = fn(1); beamX = p1[0]; beamY = p1[1]; }
// ---------------------------------------------------------------- 3D head (procedural, model units: head height 2)
const gauss = (d, s) => Math.exp(-(d / s) * (d / s));
const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const HR = { x: 0.74, y: 1.0, z: 0.82 };
const taper = y => 1 - 0.34 * sstep(-0.15, -0.95, y) - 0.06 * sstep(0.55, 1.0, y); // jaw and crown
function relief(x, y) { // forward displacement that sculpts the face
const ax = Math.abs(x);
return 0.22 * gauss(x, 0.075) * gauss(y + 0.10, 0.20) // nose bridge
+ 0.13 * gauss(x, 0.10) * gauss(y + 0.32, 0.08) // nose tip
- 0.12 * gauss(ax - 0.31, 0.14) * gauss(y - 0.12, 0.09) // eye sockets
+ 0.05 * gauss(y - 0.27, 0.06) * gauss(x, 0.45) // brow
+ 0.04 * gauss(ax - 0.38, 0.12) * gauss(y + 0.06, 0.12) // cheekbones
+ 0.07 * gauss(x, 0.17) * gauss(y + 0.53, 0.07) // lips
- 0.035 * gauss(x, 0.16) * gauss(y + 0.53, 0.018) // mouth line
+ 0.05 * gauss(x, 0.15) * gauss(y + 0.82, 0.08); // chin
}
function surf(th, ph) { // th: 0 at crown, ph: 0 facing the viewer
const y = HR.y * Math.cos(th), s = Math.sin(th);
const x = HR.x * taper(y) * s * Math.sin(ph);
const z = HR.z * s * Math.cos(ph) + relief(x, y) * Math.pow(Math.max(0, Math.cos(ph)), 1.5);
return [x, y, z];
}
function surfAt(x, y, lift) { // the surface point in front of (x, y)
const c = Math.max(-0.999, Math.min(0.999, y / HR.y)), s = Math.sqrt(1 - c * c);
const sp = Math.max(-1, Math.min(1, x / (HR.x * taper(y) * s))), cp = Math.sqrt(1 - sp * sp);
return [x, y, HR.z * s * cp + relief(x, y) * Math.pow(cp, 1.5) + lift];
}
// feature contours, as (x, y) control points laid onto the surface
function contour(ctrl, lift, n = 60) {
const out = [];
for (let i = 0; i <= n; i++) {
const f = i / n * (ctrl.length - 1), k = Math.min(ctrl.length - 2, Math.floor(f)), t = f - k;
// Catmull-Rom through the control points
const p0 = ctrl[Math.max(0, k - 1)], p1 = ctrl[k], p2 = ctrl[k + 1], p3 = ctrl[Math.min(ctrl.length - 1, k + 2)];
const cr = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (-a + 3 * b - 3 * c + d) * t * t * t);
out.push(surfAt(cr(p0[0], p1[0], p2[0], p3[0]), cr(p0[1], p1[1], p2[1], p3[1]), lift));
}
return out;
}
const FEATURES = [
contour([[-0.05, 0.07], [-0.07, -0.14], [-0.09, -0.29], [-0.12, -0.36], [-0.06, -0.40], [0, -0.39], [0.06, -0.40], [0.12, -0.36], [0.09, -0.29], [0.07, -0.14], [0.05, 0.07]], 0.012, 90), // nose
contour([[-0.47, 0.20], [-0.38, 0.265], [-0.26, 0.28], [-0.15, 0.25]], 0.01), // right brow
contour([[0.15, 0.25], [0.26, 0.28], [0.38, 0.265], [0.47, 0.20]], 0.01), // left brow
contour([[-0.22, -0.58], [-0.1, -0.63], [0, -0.64], [0.1, -0.63], [0.22, -0.58]], 0.012), // lower lip
];
let MESH = [];
function buildMesh(rings) {
const meridians = Math.round(rings * 1.15);
const P0 = -105 * Math.PI / 180, P1 = 105 * Math.PI / 180, T0 = 10 * Math.PI / 180, T1 = 152 * Math.PI / 180;
MESH = [];
for (let i = 0; i < rings; i++) {
const th = T0 + (T1 - T0) * i / (rings - 1), line = [];
for (let j = 0; j <= 60; j++) line.push(surf(th, P0 + (P1 - P0) * j / 60));
MESH.push(i % 2 ? line : line.reverse()); // alternate direction: shorter retrace, as a vector display would
}
for (let i = 0; i < meridians; i++) {
const ph = P0 + (P1 - P0) * i / (meridians - 1), line = [];
for (let j = 0; j <= 50; j++) line.push(surf(T0 + (T1 - T0) * j / 50, ph));
MESH.push(i % 2 ? line.reverse() : line);
}
return meridians;
}
// low-poly variant: a coarse vertex grid joined by straight edges, each quad split by a
// diagonal that alternates direction so the facets mirror about the nose
let LOWPOLY = [];
function buildLowPoly(rings) {
const cols = rings + 3; // odd-ish width so one column runs down the nose
const P0 = -100 * Math.PI / 180, P1 = 100 * Math.PI / 180, T0 = 14 * Math.PI / 180, T1 = 150 * Math.PI / 180;
const V = [];
for (let i = 0; i < rings; i++) {
const th = T0 + (T1 - T0) * i / (rings - 1), row = [];
for (let j = 0; j < cols; j++) row.push(surf(th, P0 + (P1 - P0) * j / (cols - 1)));
V.push(row);
}
LOWPOLY = [];
for (let i = 0; i < rings; i++) LOWPOLY.push(i % 2 ? V[i].slice().reverse() : V[i]); // rings
for (let j = 0; j < cols; j++) { const m = V.map(r => r[j]); LOWPOLY.push(j % 2 ? m.reverse() : m); } // meridians
for (let i = 0; i < rings - 1; i++) { // zigzag diagonals
const z = [];
for (let j = 0; j < cols; j++) z.push(V[j % 2 ? i + 1 : i][j]);
LOWPOLY.push(i % 2 ? z.reverse() : z);
}
return cols;
}
const R = { cy: 1, sy: 0, cp: 1, sp: 0, cr: 1, sr: 0 };
function setRot(yaw, pitch, roll) {
R.cy = Math.cos(yaw); R.sy = Math.sin(yaw); R.cp = Math.cos(pitch); R.sp = Math.sin(pitch); R.cr = Math.cos(roll); R.sr = Math.sin(roll);
}
function rot(x, y, z) {
const x1 = x * R.cr - y * R.sr, y1 = x * R.sr + y * R.cr; // roll
const y2 = y1 * R.cp + z * R.sp, z2 = -y1 * R.sp + z * R.cp; // pitch: + looks up
return [x1 * R.cy + z2 * R.sy, y2, -x1 * R.sy + z2 * R.cy]; // yaw: + turns to viewer's right
}
function proj(p) {
const [x, y, z] = rot(p[0], p[1], p[2]);
const k = 84 / (1 - z / 5.5); // gentle perspective
// approximate surface normal (ellipsoid gradient) to tell front-facing from back-facing
const tx = HR.x * taper(p[1]);
const n = rot(p[0] / (tx * tx), p[1] / (HR.y * HR.y), p[2] / (HR.z * HR.z));
return [120 + x * k, 124 - y * k, z, n[2] / Math.hypot(n[0], n[1], n[2])];
}
// intensity depth cueing: far lines dimmer, the classic vector-display trick
const cue = z => 0.3 + 0.7 * Math.min(1, Math.max(0, (z + 0.55) / 1.45));
// floor: how much of a back-facing line still shows (wireframe see-through vs hidden)
function poly3(pts, e, step, floor) {
const q = pts.map(proj);
jumpTo(q[0][0], q[0][1], e);
for (let i = 1; i < q.length; i++) {
const a = q[i - 1], b = q[i];
const n = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step));
for (let j = 1; j <= n; j++) {
const t = j / n;
const vis = floor + (1 - floor) * sstep(-0.12, 0.22, a[3] + (b[3] - a[3]) * t);
deposit(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, e * vis * cue(a[2] + (b[2] - a[2]) * t));
}
}
const L = q[q.length - 1]; beamX = L[0]; beamY = L[1];
}
function drawHead(T, E) {
setRot(A.yaw, A.pitch, A.roll);
// the wireframe: dim, constant-speed beam; back of the head fainter
if (S.mesh > 0) {
if (S.mode === 'lowpoly') {
for (const line of LOWPOLY) poly3(line, E * 0.75 * S.mesh, 0.85, 0.25);
} else {
for (const line of MESH) poly3(line, E * 0.6 * S.mesh, 0.85, 0.3);
// contour lines that make it read as a face from the front: nose, brows, lower lip
for (const line of FEATURES) poly3(line, E * 0.95 * S.mesh, 0.7, 0.1);
}
}
// eyes on the sockets: loops that become Lissajous figures while thinking
const rx = 0.135 * A.eyeScale, ry = 0.085 * A.eyeScale * A.open;
const listenPulse = state === 'listening' ? 0.15 * voice(T, 9, 190, 4.2).env : 0;
[-1, 1].forEach((side, idx) => {
const cx = 0.31 * side, cy = 0.125, ph = T * 1.6 + idx * Math.PI * 0.5, k = A.think, pts = [];
for (let j = 0; j <= 72; j++) {
const th = j / 72 * Math.PI * 2;
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
const lx = rx * Math.sin(3 * th + ph), ly = Math.max(ry, 0.07) * Math.sin(2 * th);
pts.push(surfAt(cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k, 0.02));
}
poly3(pts, E * (1.15 + listenPulse), 0.5, 0.04);
});
// mouth along the lips: smile at rest, the live waveform when speaking or listening
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
const speakAmp = 0.15 * A.mouthSpeak * sv.env, listenAmp = 0.07 * A.mouthListen * lv.env;
const smile = 0.045 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
const half = 0.24 * (1 - 0.4 * A.think), pts = [];
for (let j = 0; j <= 90; j++) {
const u = j / 90, c = 2 * u - 1;
let y = -0.53 - smile * (1 - c * c) + smile * 0.4;
if (speakAmp > 0.003) y += speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 0.03 * A.mouthSpeak * Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31) : 0);
if (listenAmp > 0.003) y += listenAmp * voiceSample(lv, u * winL, 0.4);
pts.push(surfAt(c * half, y, 0.025));
}
poly3(pts, E * 0.85 * (1 - 0.3 * A.mouthListen), 0.5, 0.04);
}
// ---------------------------------------------------------------- face geometry
function drawFace(T, dt) {
// energy per beam sample: constant energy per second whatever the refresh rate,
// and partly normalised so persistence mostly changes the trails, not the brightness
const E = 0.45 * (dt * 30) * (S.style === 'vector' ? 1 : S.intensity * Math.pow(70 / S.persistMs, 0.6));
if (S.mode !== 'flat') { drawHead(T, E); return; }
const ex = A.panX, ey = A.tiltY;
const eyes = [[82 + ex, 98 + ey], [158 + ex, 98 + ey]];
const rx = 21 * A.eyeScale, ry = 25 * A.eyeScale * A.open;
const listenPulse = state === 'listening' ? 0.12 * voice(T, 9, 190, 4.2).env : 0;
// eyes: ellipse morphing to a rotating 3:2 Lissajous figure while thinking
eyes.forEach(([cx, cy], idx) => {
const ph = T * 1.6 + idx * Math.PI * 0.5;
trace(u => {
const th = u * Math.PI * 2;
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
const lx = rx * 0.95 * Math.sin(3 * th + ph), ly = ry * 0.95 * Math.sin(2 * th);
const k = A.think;
return [cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k];
}, 260 + Math.round(160 * A.think), E * (1 + listenPulse));
});
// mouth: smile curve at rest, live waveform when speaking or listening
const mx0 = 70 + ex * 0.6, mx1 = 170 + ex * 0.6, my = 166 + ey * 0.6;
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
const speakAmp = 26 * A.mouthSpeak * sv.env, listenAmp = 12 * A.mouthListen * lv.env;
const smile = 7 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
const thinkFlat = A.think;
const mouthW = (mx1 - mx0) * (1 - 0.45 * thinkFlat);
const mStart = (mx0 + mx1) / 2 - mouthW / 2;
trace(u => {
const x = mStart + u * mouthW;
const c = 2 * u - 1;
let y = my + smile * (1 - c * c) - smile * 0.4;
if (speakAmp > 0.05) y -= speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 5 * A.mouthSpeak * (Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31)) : 0);
if (listenAmp > 0.05) y -= listenAmp * voiceSample(lv, u * winL, 0.4);
return [x, y];
}, 320, E * 0.65 * (0.95 - 0.3 * A.mouthListen));
jumpTo(eyes[0][0] - rx, eyes[0][1], E);
}
// ---------------------------------------------------------------- per-frame update
function update(T, dt) {
// sequence
if (S.sequence) {
if (T - seqT0 >= SEQ[seqIdx][1]) { seqIdx = (seqIdx + 1) % SEQ.length; seqT0 = T; setState(SEQ[seqIdx][0], T); }
} else if (state === 'looking' && T - stateT0 > 1.4) {
setState('idle', T);
}
const st = state, inLook = st === 'looking', tl = T - stateT0;
A.think = approach(A.think, st === 'thinking' ? 1 : 0, 6, dt);
A.mouthSpeak = approach(A.mouthSpeak, st === 'speaking' ? 1 : 0, 10, dt);
A.mouthListen = approach(A.mouthListen, st === 'listening' ? 1 : 0, 8, dt);
A.smile = approach(A.smile, st === 'idle' ? 1 : st === 'speaking' ? 0.6 : 0.25, 4, dt);
let scaleT = st === 'listening' ? 1.08 : 1;
if (inLook) scaleT = tl < 0.18 ? 0.92 : 1.14;
A.eyeScale = approach(A.eyeScale, scaleT, 12, dt);
A.panX = approach(A.panX, -S.pan * 0.22, 7, dt); // positive pan = robot's left = viewer's right
A.tiltY = approach(A.tiltY, -S.tilt * 0.3, 7, dt);
// head pose: the servo angles, plus small gestures per state
const deg = Math.PI / 180;
let yawT = S.pan * deg, pitchT = S.tilt * deg, rollT = 0;
if (!reduceMotion) { yawT += 3.5 * deg * Math.sin(T * 0.37); pitchT += 2 * deg * Math.sin(T * 0.29); }
if (st === 'thinking') { yawT -= 11 * deg; pitchT += 9 * deg; } // glance up and aside
if (st === 'listening') { rollT = 7 * deg; pitchT -= 2 * deg; } // cock the head
if (st === 'speaking') pitchT += 3 * deg * voice(T, 1, 118, 4.6).env; // small nods on stressed syllables
if (inLook) pitchT -= 3 * deg;
A.yaw = approach(A.yaw, yawT, 5, dt);
A.pitch = approach(A.pitch, pitchT, 5, dt);
A.roll = approach(A.roll, rollT, 4, dt);
// blink (and the squint before a photo)
let openT = 1;
if (inLook && tl < 0.18) openT = 0.35;
if (st !== 'thinking' && !inLook) {
if (blinkT < 0 && T >= nextBlink) blinkT = T;
if (blinkT >= 0) {
const b = (T - blinkT) / 0.17;
if (b >= 1) { blinkT = -1; nextBlink = T + 2.2 + Math.random() * 4; }
else openT = Math.max(0.06, Math.abs(1 - 2 * b));
}
}
A.open = blinkT >= 0 ? openT : approach(A.open, openT, 18, dt);
if (lookFlashPending && tl > 0.16) { // shutter: bloom the whole tube once
if (S.style === 'phosphor') for (let i = 0; i < N; i++) buf[i] += 0.55;
else flash = 0.6; // vector render has no afterglow, so fade an overlay instead
lookFlashPending = false;
}
flash = Math.max(0, flash - dt * 2.5);
}
// ---------------------------------------------------------------- tone mapping
function boxBlur(src, dst, r) {
// horizontal into tmp, vertical into dst
const inv = 1 / (2 * r + 1);
for (let y = 0; y < H; y++) {
let acc = 0, row = y * W;
for (let x = -r; x <= r; x++) acc += src[row + Math.min(W - 1, Math.max(0, x))];
for (let x = 0; x < W; x++) {
tmp[row + x] = acc * inv;
acc += src[row + Math.min(W - 1, x + r + 1)] - src[row + Math.max(0, x - r)];
}
}
for (let x = 0; x < W; x++) {
let acc = 0;
for (let y = -r; y <= r; y++) acc += tmp[Math.min(H - 1, Math.max(0, y)) * W + x];
for (let y = 0; y < H; y++) {
dst[y * W + x] = acc * inv;
acc += tmp[Math.min(H - 1, y + r + 1) * W + x] - tmp[Math.max(0, y - r) * W + x];
}
}
}
let flash = 0;
function render() {
if (S.style === 'vector') return renderVector();
const r = 1 + Math.round(S.focus * 5);
boxBlur(buf, glow, r);
boxBlur(glow, glow, r);
const gAmt = 0.6 + S.focus * 2.2;
const core = 1.15 - S.focus * 0.55;
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
const d = img.data;
for (let i = 0, j = 0; i < N; i++, j += 4) {
const v = buf[i] * core + glow[i] * gAmt;
const base = 1 - Math.exp(-v * 1.6);
const hot = Math.min(0.75, Math.max(0, v - 1.1) * 0.35); // overdriven spots whiten
d[j] = 255 * Math.min(1, pr * base + hot);
d[j + 1] = 255 * Math.min(1, pg * base + hot);
d[j + 2] = 255 * Math.min(1, pb * base + hot);
d[j + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// clean vector look: this frame's lines only, saturated to an even weight, a faint halo, no trails
function renderVector() {
boxBlur(buf, glow, 1);
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
const lift = 0.35; // pale the tint so lines read as light, not as phosphor
const cr = pr + (1 - pr) * lift, cg = pg + (1 - pg) * lift, cb = pb + (1 - pb) * lift;
const d = img.data, k = 2.6 * S.intensity;
for (let i = 0, j = 0; i < N; i++, j += 4) {
const v = Math.min(1, buf[i] * k) * 0.9 + Math.min(1, glow[i] * k) * 0.25 + flash * 0.35;
d[j] = 255 * Math.min(1, cr * v);
d[j + 1] = 255 * Math.min(1, cg * v);
d[j + 2] = 255 * Math.min(1, cb * v);
d[j + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// ---------------------------------------------------------------- loop
let last = performance.now(), acc = 0, frames = 0, fpsT = last;
function frame(nowMs) {
requestAnimationFrame(frame);
const step = 1000 / S.fps;
acc += nowMs - last; last = nowMs;
if (acc < step - 1) return;
const dt = Math.min(0.1, acc / 1000);
acc = acc % step;
const T = nowMs / 1000;
const decay = S.style === 'vector' ? 0 : Math.exp(-(dt * 1000) / S.persistMs);
if (decay === 0) buf.fill(0); else for (let i = 0; i < N; i++) buf[i] *= decay;
update(T, dt);
ptsThisFrame = 0;
drawFace(T, dt);
render();
frames++;
if (nowMs - fpsT > 500) {
document.getElementById('roFps').textContent = Math.round(frames * 1000 / (nowMs - fpsT));
document.getElementById('roPts').textContent = ptsThisFrame;
document.getElementById('roDecay').textContent = decay.toFixed(2);
frames = 0; fpsT = nowMs;
}
}
// ---------------------------------------------------------------- graticule overlay
function drawGraticule() {
const g = document.getElementById('grat'), c = g.getContext('2d');
c.clearRect(0, 0, g.width, g.height);
if (!S.graticule) return;
const s = g.width / 10;
c.strokeStyle = 'rgba(150, 175, 160, 0.22)'; c.lineWidth = 1;
for (let i = 1; i < 10; i++) {
c.beginPath(); c.moveTo(i * s + .5, 0); c.lineTo(i * s + .5, g.height); c.stroke();
c.beginPath(); c.moveTo(0, i * s + .5); c.lineTo(g.width, i * s + .5); c.stroke();
}
c.strokeStyle = 'rgba(150, 175, 160, 0.32)';
for (let i = 1; i < 50; i++) {
const p = i * s / 5 + .5, m = g.width / 2;
c.beginPath(); c.moveTo(p, m - 4); c.lineTo(p, m + 4); c.stroke();
c.beginPath(); c.moveTo(m - 4, p); c.lineTo(m + 4, p); c.stroke();
}
}
// ---------------------------------------------------------------- controls
const $ = id => document.getElementById(id);
function pressOnly(groupSel, btn) { document.querySelectorAll(groupSel + ' button').forEach(b => b.setAttribute('aria-pressed', String(b === btn))); }
document.querySelectorAll('#states .key').forEach(b => b.addEventListener('click', () => {
S.sequence = false; $('seqBtn').setAttribute('aria-pressed', 'false');
setState(b.dataset.state, performance.now() / 1000);
}));
function toggleSeq() {
S.sequence = !S.sequence; $('seqBtn').setAttribute('aria-pressed', String(S.sequence));
if (S.sequence) { seqIdx = 0; seqT0 = performance.now() / 1000; setState(SEQ[0][0], seqT0); }
}
$('seqBtn').addEventListener('click', toggleSeq);
document.querySelectorAll('#phosSeg button').forEach(b => b.addEventListener('click', () => {
S.phos = b.dataset.p; pressOnly('#phosSeg', b); $('phosNote').textContent = PHOSPHORS[S.phos].note;
}));
document.querySelectorAll('#fpsSeg button').forEach(b => b.addEventListener('click', () => { S.fps = +b.dataset.fps; pressOnly('#fpsSeg', b); }));
function bindRange(id, fn) { const el = $(id); const go = () => fn(+el.value); el.addEventListener('input', go); go(); }
bindRange('persist', v => { S.persistMs = v; $('persistOut').textContent = v + ' ms'; });
bindRange('inten', v => { S.intensity = v / 100; $('intenOut').textContent = (v / 100).toFixed(1) + '×'; });
bindRange('focus', v => { S.focus = v / 100; $('focusOut').textContent = v < 20 ? 'sharp' : v < 55 ? 'soft' : v < 80 ? 'bloomy' : 'smeared'; });
bindRange('jitter', v => { S.jitter = v / 10; $('jitterOut').textContent = (v / 10).toFixed(1) + ' px'; });
const sign = v => (v > 0 ? '+' : v < 0 ? '−' : '+') + Math.abs(v) + '°';
bindRange('pan', v => { S.pan = v; $('panOut').textContent = sign(v); });
bindRange('tilt', v => { S.tilt = v; $('tiltOut').textContent = sign(v); });
$('pixBtn').addEventListener('click', () => {
S.pixels = !S.pixels; $('pixBtn').setAttribute('aria-pressed', String(S.pixels));
$('screen').classList.toggle('pixels', S.pixels);
});
$('gratBtn').addEventListener('click', () => {
S.graticule = !S.graticule; $('gratBtn').setAttribute('aria-pressed', String(S.graticule)); drawGraticule();
});
const MODE_NOTE = {
flat: 'Flat face; only the eyes follow the servos.',
head: 'Smooth wireframe head that turns with the servos.',
lowpoly: 'Faceted head: a coarse mesh of straight edges, turning with the servos.',
};
const MODES = ['flat', 'head', 'lowpoly'];
let lowpolyCols = 0, meshCols = 0;
function densityLabel() {
const v = S.density;
$('densityOut').textContent = S.mode === 'lowpoly' ? Math.max(5, Math.round(v * 0.62)) + ' × ' + lowpolyCols : v + ' × ' + meshCols;
}
function setMode(m) {
S.mode = m;
pressOnly('#modeSeg', $('mode-' + m));
$('modeNote').textContent = MODE_NOTE[m];
$('meshKnobs').hidden = m === 'flat';
densityLabel();
}
function setStyle(s) {
S.style = s;
pressOnly('#styleSeg', $('style-' + s));
$('vectorNote').hidden = s !== 'vector';
buf.fill(0);
}
document.querySelectorAll('#modeSeg button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
document.querySelectorAll('#styleSeg button').forEach(b => b.addEventListener('click', () => setStyle(b.dataset.style)));
bindRange('mesh', v => { S.mesh = v / 100; $('meshOut').textContent = v + '%'; });
bindRange('density', v => {
S.density = v;
meshCols = buildMesh(v);
lowpolyCols = buildLowPoly(Math.max(5, Math.round(v * 0.62)));
densityLabel();
});
document.addEventListener('keydown', e => {
if (e.target.closest && e.target.closest('input')) return;
const n = parseInt(e.key, 10);
if (e.key === 'f' || e.key === 'F') { setMode(MODES[(MODES.indexOf(S.mode) + 1) % MODES.length]); return; }
if (e.key === 'v' || e.key === 'V') { setStyle(S.style === 'vector' ? 'phosphor' : 'vector'); return; }
if (n >= 1 && n <= 5) { $(['k-idle', 'k-listen', 'k-think', 'k-look', 'k-speak'][n - 1]).click(); }
else if (e.code === 'Space') { e.preventDefault(); toggleSeq(); }
});
if (!S.sequence) $('seqBtn').setAttribute('aria-pressed', 'false');
if (reduceMotion) { S.jitter = 0; $('jitter').value = 0; $('jitterOut').textContent = '0.0 px'; }
drawGraticule();
const t0 = performance.now() / 1000; seqT0 = t0; stateT0 = t0; nextBlink = t0 + 2.5;
requestAnimationFrame(frame);
})();
</script>