feat: replace orb avatar with a livekit-style LED wall driven by agent audio
Rebuild & Deploy / rebuild (push) Successful in 37s
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:
1 parent
e2626ba54e
commit
68f4c461c8
3 files changed
+148
-157
No files matched your search
+87
-11
@@ -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();
|
||||
Reference in new issue
Block a user