feat: replace orb avatar with a livekit-style LED wall driven by agent audio
Rebuild & Deploy / rebuild (push) Successful in 37s

- Swap the CSS orb for a 21x21 LED dot matrix on a glass panel, matching
  the livekit.com LED-array hero visual.
- Pulse driven by the agent's rendered audio: an expanding radial bloom
  brightens and widens with remote-audio level, dimmer when listening on
  the mic, faint static matrix when idle, and a subtle warm breathing
  state while thinking.
- State-tinted palettes: cool azure when speaking, blue when listening,
  amber when thinking, dim slate when idle.
- Drop the unused orb CSS-variable pulse plumbing and the presence-breathe
  / orbit-turn keyframes; the wall renders per-frame from the analyser.
This commit is contained in:
snowspeeder committed 2026-08-29 17:05:57 -04:00
1 parent e2626ba54e
commit 68f4c461c8
3 files changed
+148 -157

No files matched your search

+87 -11
View File
@@ -58,7 +58,7 @@ const muteBtn = document.getElementById("muteBtn");
let micMuted = false; let micMuted = false;
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const messagesEl = document.getElementById("messages"); const messagesEl = document.getElementById("messages");
const orbEl = document.getElementById("orb"); const ledWallEl = document.getElementById("ledWall");
const stateLabelEl = document.getElementById("stateLabel"); const stateLabelEl = document.getElementById("stateLabel");
const thinkingChipEl = document.getElementById("thinkingChip"); const thinkingChipEl = document.getElementById("thinkingChip");
const bargeHintEl = document.getElementById("bargeHint"); const bargeHintEl = document.getElementById("bargeHint");
@@ -262,6 +262,84 @@ function teardownAudio() {
remoteCtx = remoteAnalyser = remoteFreqBuf = null; 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() { function readMicLevel() {
let level = 0; let level = 0;
if (micAnalyser && micBuf) { if (micAnalyser && micBuf) {
@@ -308,7 +386,7 @@ const STATE_LABELS = {
function setState(next) { function setState(next) {
uiState = next; uiState = next;
orbEl.dataset.state = next; ledWallEl.dataset.state = next;
stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next; stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next;
// Thinking timer: show the "thinking..." chip if it lingers past 2s // 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) // Mic meter (thin bar under the status line)
micMeterFillEl.style.width = `${Math.min(100, Math.round(micLevel * 250))}%`; 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") { if (uiState === "speaking") {
orbEl.style.setProperty("--pulse-scale", (1 + agentAudioLevel * 0.35).toFixed(3)); renderLedWall(agentAudioLevel, "speaking");
orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.55 + agentAudioLevel).toFixed(3));
} else if (uiState === "listening" && userSpeaking) { } else if (uiState === "listening" && userSpeaking) {
orbEl.style.setProperty("--pulse-scale", (1 + micLevel * 0.4).toFixed(3)); renderLedWall(micLevel, "listening");
orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.5 + micLevel).toFixed(3));
} else { } else {
orbEl.style.setProperty("--pulse-scale", "1"); renderLedWall(0, "idle");
orbEl.style.setProperty("--pulse-opacity", "");
} }
// Agent audio visualizer bars on the latest agent bubble // Agent audio visualizer bars on the latest agent bubble
@@ -393,8 +469,7 @@ function stopVisualLoop() {
if (visualTimer) clearInterval(visualTimer); if (visualTimer) clearInterval(visualTimer);
visualTimer = null; visualTimer = null;
micMeterFillEl.style.width = "0%"; micMeterFillEl.style.width = "0%";
orbEl.style.setProperty("--pulse-scale", "1"); renderLedWall(0, "idle");
orbEl.style.setProperty("--pulse-opacity", "");
} }
// ── Conversation history (localStorage) ───────────────────────────────────── // ── Conversation history (localStorage) ─────────────────────────────────────
@@ -1587,3 +1662,4 @@ closeMediaBtn.addEventListener("click", () => setMediaOpen(false));
startMediaPolling(); startMediaPolling();
updateMediaControls(); updateMediaControls();
buildLedWall();
+1 -6
View File
@@ -58,12 +58,7 @@
</div> </div>
<div class="orb-stage"> <div class="orb-stage">
<div class="orb" id="orb" data-state="idle" aria-hidden="true"> <div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div>
<div class="orb-orbit orbit-one"></div>
<div class="orb-orbit orbit-two"></div>
<div class="orb-core"><span class="orb-shimmer"></span></div>
<div class="orb-ring"></div>
</div>
<div class="orb-labels" aria-live="polite"> <div class="orb-labels" aria-live="polite">
<span class="state-label" id="stateLabel">Ready when you are</span> <span class="state-label" id="stateLabel">Ready when you are</span>
<span class="thinking-chip" id="thinkingChip" hidden>Thinking&hellip;</span> <span class="thinking-chip" id="thinkingChip" hidden>Thinking&hellip;</span>
+60 -140
View File
@@ -327,149 +327,68 @@ button {
padding-top: 0.75rem; padding-top: 0.75rem;
} }
.orb { /* ── LED wall avatar ────────────────────────────────────────────────────── */
--pulse-scale: 1; .led-wall {
--pulse-opacity: 0.28;
position: relative; 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; 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 { /* A soft glass panel that holds the matrix up off the page. */
width: 214px; .led-wall::before {
height: 140px;
transform: rotate(-18deg);
}
.orbit-one::after {
content: ""; content: "";
position: absolute; position: absolute;
width: 7px; inset: 0;
height: 7px; border-radius: 26px;
top: 12px; border: 1px solid rgba(139, 167, 255, 0.14);
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 {
background: background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9) 0 3%, transparent 20%), radial-gradient(circle at 50% 50%, rgba(111, 143, 247, 0.1), transparent 62%),
radial-gradient(circle at 38% 30%, #f3c9b5, #a783ca 42%, #5d68bd 72%, #29365f 100%); rgba(6, 12, 24, 0.4);
box-shadow: 0 0 62px rgba(167, 131, 202, 0.48); z-index: -1;
} }
.orb[data-state="speaking"] .orb-core { .led-dot {
box-shadow: --sz: 5px;
0 0 0 1px rgba(213, 222, 255, 0.45), width: var(--sz);
0 12px 28px rgba(2, 8, 20, 0.34), height: var(--sz);
0 0 78px rgba(111, 143, 247, 0.64), min-width: 3px;
0 0 105px rgba(255, 179, 142, 0.11); 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 { /* Palette shifts by state — a cool blue while it listens, warm toward the
background: #ffd0b8; center as the agent speaks, amber while thinking, dim when idle. */
.led-wall[data-state="idle"] {
color: #56739e;
} }
@keyframes presence-breathe { .led-wall[data-state="connecting"],
0%, 100% { transform: scale(calc(var(--pulse-scale) * 0.98)); } .led-wall[data-state="thinking"] {
50% { transform: scale(calc(var(--pulse-scale) * 1.045)); } color: #d99a6c;
} }
@keyframes orbit-turn { .led-wall[data-state="listening"] {
to { transform: rotate(342deg); } color: #6f8ff7;
}
.led-wall[data-state="speaking"] {
color: #9dc2ff;
filter: drop-shadow(0 0 14px rgba(111, 143, 247, 0.35));
} }
.orb-labels { .orb-labels {
@@ -964,10 +883,9 @@ body[data-mode="text"] .orb-stage {
min-height: 220px; min-height: 220px;
} }
body[data-mode="text"] .orb { body[data-mode="text"] .led-wall {
width: 180px; width: 260px;
height: 180px; height: 260px;
transform: scale(0.72);
} }
body[data-mode="text"] .controls { body[data-mode="text"] .controls {
@@ -1548,8 +1466,9 @@ body[data-mode="text"] .mic-meter {
margin: -1rem 0; margin: -1rem 0;
} }
.orb { .led-wall {
transform: scale(0.78); width: 260px;
height: 260px;
} }
.connection-strip, .connection-strip,
@@ -1628,8 +1547,9 @@ body[data-mode="text"] .mic-meter {
margin: -0.75rem 0; margin: -0.75rem 0;
} }
body[data-mode="text"] .orb { body[data-mode="text"] .led-wall {
transform: scale(0.48); width: 210px;
height: 210px;
} }
.console-intro h2 { .console-intro h2 {
@@ -1640,10 +1560,10 @@ body[data-mode="text"] .mic-meter {
min-height: 300px; min-height: 300px;
} }
.orb { .led-wall {
width: 235px; width: 270px;
height: 235px; height: 270px;
transform: scale(0.94); padding: 9px;
} }
.controls { .controls {