diff --git a/web/app.js b/web/app.js index 1e62fe5..55d4ba5 100644 --- a/web/app.js +++ b/web/app.js @@ -58,7 +58,7 @@ const muteBtn = document.getElementById("muteBtn"); let micMuted = false; const statusEl = document.getElementById("status"); const messagesEl = document.getElementById("messages"); -const orbEl = document.getElementById("orb"); +const ledWallEl = document.getElementById("ledWall"); const stateLabelEl = document.getElementById("stateLabel"); const thinkingChipEl = document.getElementById("thinkingChip"); const bargeHintEl = document.getElementById("bargeHint"); @@ -262,6 +262,84 @@ function teardownAudio() { remoteCtx = remoteAnalyser = remoteFreqBuf = null; } +// ── LED wall — a grid of dots that pulses with the agent's audio ──────────── +const LED_COLS = 21; +const LED_ROWS = 21; +const LED_GROW = 1.4; // how much the dots grow toward the center +let ledDots = []; +let ledWallTime = 0; + +function buildLedWall() { + const inner = Math.floor(LED_COLS / 2); + const frag = document.createDocumentFragment(); + for (let r = 0; r < LED_ROWS; r++) { + for (let c = 0; c < LED_COLS; c++) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "led-dot"; + b.disabled = true; + b.tabIndex = -1; + // Radial position: 0 at the center, 1 at the corner + const dx = (c - inner) / (LED_COLS / 2); + const dy = (r - inner) / (LED_ROWS / 2); + const rad = Math.sqrt(dx * dx + dy * dy); + // Push a radial cluster toward the center; scale its base brightness down + // as it moves outward so the wall reads as a soft round bloom. + b.dataset.rad = rad.toFixed(4); + // A per-dot phase so the wave ripples rather than flashing in unison. + b.dataset.phase = (Math.sin(r * 12.9898 + c * 78.233) * 43758.5453 % 1 * Math.PI * 2).toFixed(3); + ledDots.push(b); + frag.appendChild(b); + } + } + ledWallEl.appendChild(frag); +} + +// Renders one frame of the LED wall. `level` is 0..1 (agent audio when speaking, +// mic when listening). `mode` picks the palette/gradient by conversation state. +function renderLedWall(level, mode) { + ledWallTime += 1; + ledWallEl.dataset.mode = mode; + const n = ledDots.length; + for (let i = 0; i < n; i++) { + const dot = ledDots[i]; + const rad = parseFloat(dot.dataset.rad); + const phase = parseFloat(dot.dataset.phase || 0); + + let vis = 0; + let size = 1; + + if (mode === "idle") { + // A faint, static dot matrix — present but clearly dormant. + vis = 0.16 - rad * 0.08; + size = 1; + } else if (level > 0.005) { + // Centered breathing bloom that widens and brightens with the + // signal. A soft gaussian falloff keeps it circular; a gentle + // per-dot phase ripple adds life without banding. + const lvl = Math.min(1, level * 1.3); + const falloff = Math.exp(-rad * rad * (2.0 - lvl * 1.1)); + const ripple = 0.62 + 0.38 * Math.sin(ledWallTime * 0.32 + phase); + vis = falloff * (0.35 + lvl) * ripple; + size = 1 + (1 - rad) * LED_GROW * lvl; + // Keep listening dimmer than speaking so the wall leans into the + // agent's output. + if (mode !== "speaking") vis *= 0.55; + } else { + // Powered state with no signal (e.g. thinking gap) — soft breathing. + const breathe = 0.5 + 0.5 * Math.sin(ledWallTime * 0.12 + phase); + vis = 0.14 - rad * 0.06 + 0.04 * breathe; + size = 1 + (1 - rad) * 0.3; + } + + vis = Math.max(0.02, Math.min(1, vis)); + // Dot grows toward the center and sits on a subtle 4px grid inset. + const px = (2 * size).toFixed(1); + dot.style.setProperty("--sz", `${px}px`); + dot.style.opacity = vis.toFixed(3); + } +} + function readMicLevel() { let level = 0; if (micAnalyser && micBuf) { @@ -308,7 +386,7 @@ const STATE_LABELS = { function setState(next) { uiState = next; - orbEl.dataset.state = next; + ledWallEl.dataset.state = next; stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next; // Thinking timer: show the "thinking..." chip if it lingers past 2s @@ -359,16 +437,14 @@ function startVisualLoop() { // Mic meter (thin bar under the status line) micMeterFillEl.style.width = `${Math.min(100, Math.round(micLevel * 250))}%`; - // Orb pulse: scale/opacity from whichever stream is active + // LED wall: pulse on the agent's rendered audio when speaking, the mic + // while listening, and hold a faint matrix when idle. if (uiState === "speaking") { - orbEl.style.setProperty("--pulse-scale", (1 + agentAudioLevel * 0.35).toFixed(3)); - orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.55 + agentAudioLevel).toFixed(3)); + renderLedWall(agentAudioLevel, "speaking"); } else if (uiState === "listening" && userSpeaking) { - orbEl.style.setProperty("--pulse-scale", (1 + micLevel * 0.4).toFixed(3)); - orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.5 + micLevel).toFixed(3)); + renderLedWall(micLevel, "listening"); } else { - orbEl.style.setProperty("--pulse-scale", "1"); - orbEl.style.setProperty("--pulse-opacity", ""); + renderLedWall(0, "idle"); } // Agent audio visualizer bars on the latest agent bubble @@ -393,8 +469,7 @@ function stopVisualLoop() { if (visualTimer) clearInterval(visualTimer); visualTimer = null; micMeterFillEl.style.width = "0%"; - orbEl.style.setProperty("--pulse-scale", "1"); - orbEl.style.setProperty("--pulse-opacity", ""); + renderLedWall(0, "idle"); } // ── Conversation history (localStorage) ───────────────────────────────────── @@ -1587,3 +1662,4 @@ closeMediaBtn.addEventListener("click", () => setMediaOpen(false)); startMediaPolling(); updateMediaControls(); +buildLedWall(); diff --git a/web/index.html b/web/index.html index 4a361b3..217ce05 100644 --- a/web/index.html +++ b/web/index.html @@ -58,12 +58,7 @@
- +
Ready when you are diff --git a/web/style.css b/web/style.css index 60cf3e8..d103b96 100644 --- a/web/style.css +++ b/web/style.css @@ -327,149 +327,68 @@ button { padding-top: 0.75rem; } -.orb { - --pulse-scale: 1; - --pulse-opacity: 0.28; +/* ── LED wall avatar ────────────────────────────────────────────────────── */ +.led-wall { position: relative; - width: 260px; - height: 260px; - display: grid; - place-items: center; - isolation: isolate; -} - -.orb-core { - position: relative; - z-index: 3; - width: 116px; - height: 116px; - overflow: hidden; - border-radius: 50%; - background: - radial-gradient(circle at 32% 24%, rgba(244, 247, 251, 0.96) 0 3%, transparent 20%), - radial-gradient(circle at 38% 30%, #b9c9ff, #6f8ff7 42%, #3d57ab 72%, #23305b 100%); - box-shadow: - 0 0 0 1px rgba(213, 222, 255, 0.24), - 0 12px 28px rgba(2, 8, 20, 0.34), - 0 0 52px rgba(111, 143, 247, 0.32), - inset -12px -14px 26px rgba(10, 20, 50, 0.34); - transform: scale(var(--pulse-scale)); - transition: filter 220ms ease, box-shadow 220ms ease, transform 90ms linear; -} - -.orb-shimmer { - position: absolute; - width: 90px; - height: 34px; - left: 9px; - top: 18px; - border-radius: 50%; - border-top: 1px solid rgba(255, 255, 255, 0.52); - transform: rotate(-22deg); - opacity: 0.7; -} - -.orb-ring, -.orb-orbit { - position: absolute; - border-radius: 50%; - pointer-events: none; -} - -.orb-ring { - z-index: 2; - width: 164px; - height: 164px; - border: 1px solid rgba(139, 167, 255, 0.2); - opacity: var(--pulse-opacity); - transform: scale(var(--pulse-scale)); - transition: opacity 180ms ease, transform 90ms linear; -} - -.orb-orbit { z-index: 1; - border: 1px solid rgba(139, 167, 255, 0.15); + width: 320px; + height: 320px; + max-width: 100%; + display: grid; + grid-template-columns: repeat(21, 1fr); + grid-template-rows: repeat(21, 1fr); + gap: 2px; + place-items: center; + padding: 12px; + border-radius: 26px; + isolation: isolate; + transition: filter 220ms ease; } -.orbit-one { - width: 214px; - height: 140px; - transform: rotate(-18deg); -} - -.orbit-one::after { +/* A soft glass panel that holds the matrix up off the page. */ +.led-wall::before { content: ""; position: absolute; - width: 7px; - height: 7px; - top: 12px; - right: 37px; - border-radius: 50%; - background: var(--warm); - box-shadow: 0 0 13px rgba(255, 179, 142, 0.65); -} - -.orbit-two { - width: 230px; - height: 230px; - border-color: rgba(139, 167, 255, 0.08); -} - -.orb[data-state="idle"] .orb-core { - filter: saturate(0.72) brightness(0.78); - box-shadow: - 0 0 0 1px rgba(213, 222, 255, 0.13), - 0 12px 28px rgba(2, 8, 20, 0.3), - 0 0 28px rgba(111, 143, 247, 0.16); -} - -.orb[data-state="connecting"] .orbit-one, -.orb[data-state="thinking"] .orbit-one { - animation: orbit-turn 3.5s linear infinite; -} - -.orb[data-state="connecting"] .orb-core { - animation: presence-breathe 1.7s ease-in-out infinite; -} - -.orb[data-state="listening"] .orb-core { - box-shadow: - 0 0 0 1px rgba(213, 222, 255, 0.4), - 0 12px 28px rgba(2, 8, 20, 0.34), - 0 0 68px rgba(111, 143, 247, 0.56), - inset -12px -14px 26px rgba(10, 20, 50, 0.28); -} - -.orb[data-state="listening"] .orb-ring { - border-color: rgba(139, 167, 255, 0.5); -} - -.orb[data-state="thinking"] .orb-core { + inset: 0; + border-radius: 26px; + border: 1px solid rgba(139, 167, 255, 0.14); background: - radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9) 0 3%, transparent 20%), - radial-gradient(circle at 38% 30%, #f3c9b5, #a783ca 42%, #5d68bd 72%, #29365f 100%); - box-shadow: 0 0 62px rgba(167, 131, 202, 0.48); + radial-gradient(circle at 50% 50%, rgba(111, 143, 247, 0.1), transparent 62%), + rgba(6, 12, 24, 0.4); + z-index: -1; } -.orb[data-state="speaking"] .orb-core { - box-shadow: - 0 0 0 1px rgba(213, 222, 255, 0.45), - 0 12px 28px rgba(2, 8, 20, 0.34), - 0 0 78px rgba(111, 143, 247, 0.64), - 0 0 105px rgba(255, 179, 142, 0.11); +.led-dot { + --sz: 5px; + width: var(--sz); + height: var(--sz); + min-width: 3px; + min-height: 3px; + border: 0; + border-radius: 50%; + background: currentColor; + opacity: 0.16; + transition: opacity 90ms linear, width 90ms linear, height 90ms linear; } -.orb[data-state="speaking"] .orbit-one::after { - background: #ffd0b8; +/* Palette shifts by state — a cool blue while it listens, warm toward the + center as the agent speaks, amber while thinking, dim when idle. */ +.led-wall[data-state="idle"] { + color: #56739e; } -@keyframes presence-breathe { - 0%, 100% { transform: scale(calc(var(--pulse-scale) * 0.98)); } - 50% { transform: scale(calc(var(--pulse-scale) * 1.045)); } +.led-wall[data-state="connecting"], +.led-wall[data-state="thinking"] { + color: #d99a6c; } -@keyframes orbit-turn { - to { transform: rotate(342deg); } +.led-wall[data-state="listening"] { + color: #6f8ff7; +} + +.led-wall[data-state="speaking"] { + color: #9dc2ff; + filter: drop-shadow(0 0 14px rgba(111, 143, 247, 0.35)); } .orb-labels { @@ -964,10 +883,9 @@ body[data-mode="text"] .orb-stage { min-height: 220px; } -body[data-mode="text"] .orb { - width: 180px; - height: 180px; - transform: scale(0.72); +body[data-mode="text"] .led-wall { + width: 260px; + height: 260px; } body[data-mode="text"] .controls { @@ -1548,8 +1466,9 @@ body[data-mode="text"] .mic-meter { margin: -1rem 0; } - .orb { - transform: scale(0.78); + .led-wall { + width: 260px; + height: 260px; } .connection-strip, @@ -1628,8 +1547,9 @@ body[data-mode="text"] .mic-meter { margin: -0.75rem 0; } - body[data-mode="text"] .orb { - transform: scale(0.48); + body[data-mode="text"] .led-wall { + width: 210px; + height: 210px; } .console-intro h2 { @@ -1640,10 +1560,10 @@ body[data-mode="text"] .mic-meter { min-height: 300px; } - .orb { - width: 235px; - height: 235px; - transform: scale(0.94); + .led-wall { + width: 270px; + height: 270px; + padding: 9px; } .controls {