From 1b4d5661509349b80b8e379e27d6b03d9a33f392 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sun, 24 May 2026 10:52:46 +1000 Subject: [PATCH] fix: smooth agent visual audio response --- src/components/chat/agent-visualizer.tsx | 108 ++++++++++++++++++----- src/components/chat/voice-agent.tsx | 4 +- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/components/chat/agent-visualizer.tsx b/src/components/chat/agent-visualizer.tsx index 2161936..c226e69 100644 --- a/src/components/chat/agent-visualizer.tsx +++ b/src/components/chat/agent-visualizer.tsx @@ -54,10 +54,15 @@ interface DrawFrame { intensity: number; reducedMotion: boolean; time: number; + primaryPhase: number; + secondaryPhase: number; + tertiaryPhase: number; width: number; height: number; } +type DrawFrameInput = Omit; + const INTENSITY_SCALE = { calm: 0.78, balanced: 1, @@ -73,9 +78,12 @@ const STYLE_LABEL: Record = { const clamp = (value: number, min = 0, max = 1) => Math.max(min, Math.min(max, value)); -function parseRgb(value: string): Rgb { - const channels = value - .split(",") +function parseRgb(value: string, element?: HTMLElement | null): Rgb { + const resolved = value.startsWith("var(") && element + ? getComputedStyle(element).getPropertyValue(value.slice(4, -1).trim()) + : value; + const channels = resolved + .split(/[,\s]+/) .map((channel) => Number.parseInt(channel.trim(), 10)) .filter((channel) => Number.isFinite(channel)); if (channels.length < 3) return [99, 183, 170]; @@ -104,6 +112,11 @@ function stateEnergy(frame: DrawFrame) { return 0.18 + frame.motionLevel * 0.22; } +function smoothLevel(current: number, target: number, delta: number) { + const rate = target > current ? 18 : 7.5; + return current + (target - current) * (1 - Math.exp(-rate * delta)); +} + function activeColor(frame: DrawFrame) { if (frame.state === "listening") return frame.listening; if (frame.state === "speaking") return frame.speaking; @@ -216,12 +229,11 @@ function drawBackground(ctx: CanvasRenderingContext2D, frame: DrawFrame, base: R } function drawOrbitalReactor(ctx: CanvasRenderingContext2D, frame: DrawFrame) { - const { width, height, time } = frame; + const { width, height } = frame; const cx = width / 2; const cy = height / 2; const size = Math.min(width, height); const energy = stateEnergy(frame); - const motion = frame.reducedMotion ? 0 : frame.motionLevel * frame.intensity; const accent = activeColor(frame); const graphite: Rgb = [38, 43, 48]; const pearl: Rgb = [236, 241, 240]; @@ -230,7 +242,7 @@ function drawOrbitalReactor(ctx: CanvasRenderingContext2D, frame: DrawFrame) { drawBackground(ctx, frame, graphite, true); glow(ctx, cx, cy, r * 1.4, accent, frame.state === "muted" ? 0.04 : 0.13 + energy * 0.1); - const rotation = time * (0.18 + motion * 0.55); + const rotation = frame.primaryPhase; for (let i = 0; i < 5; i += 1) { const radius = r * (0.48 + i * 0.16); const alpha = 0.11 + i * 0.035; @@ -238,7 +250,7 @@ function drawOrbitalReactor(ctx: CanvasRenderingContext2D, frame: DrawFrame) { } for (let i = 0; i < 9; i += 1) { - const offset = rotation + i * 0.72; + const offset = rotation * (i % 2 ? -0.62 : 1) + i * 0.72; const radius = r * (0.57 + (i % 3) * 0.18); const length = 0.22 + energy * 0.28 + (i % 2) * 0.08; const stroke = i % 3 === 0 ? accent : mix(accent, pearl, 0.42); @@ -246,7 +258,7 @@ function drawOrbitalReactor(ctx: CanvasRenderingContext2D, frame: DrawFrame) { } for (let i = 0; i < 24; i += 1) { - const angle = rotation * 0.7 + (i / 24) * Math.PI * 2; + const angle = frame.secondaryPhase + (i / 24) * Math.PI * 2; const radius = r * (0.8 + (i % 4) * 0.045); const x = cx + Math.cos(angle) * radius; const y = cy + Math.sin(angle) * radius; @@ -263,6 +275,27 @@ function drawOrbitalReactor(ctx: CanvasRenderingContext2D, frame: DrawFrame) { ctx.arc(cx, cy, r * (0.19 + energy * 0.035), 0, Math.PI * 2); ctx.fill(); circle(ctx, cx, cy, r * (0.21 + energy * 0.03), rgba(pearl, 0.42), 1.7); + if (frame.state === "speaking") { + for (let i = 0; i < 3; i += 1) { + const wave = (frame.tertiaryPhase * 0.45 + i / 3) % 1; + circle(ctx, cx, cy, r * (0.32 + wave * 0.82), rgba(frame.speaking, (1 - wave) * 0.28), 1.8 + energy * 1.8); + } + } else if (frame.state === "listening") { + for (let i = 0; i < 18; i += 1) { + const angle = -frame.tertiaryPhase * 0.8 + (i / 18) * Math.PI * 2; + const inner = r * (0.2 + frame.visualVolume * 0.08); + const outer = r * (0.38 + frame.visualVolume * 0.18); + line( + ctx, + cx + Math.cos(angle) * inner, + cy + Math.sin(angle) * inner, + cx + Math.cos(angle) * outer, + cy + Math.sin(angle) * outer, + rgba(frame.listening, 0.1 + frame.visualVolume * 0.22), + 1, + ); + } + } } function drawNeuralConstellation(ctx: CanvasRenderingContext2D, frame: DrawFrame) { @@ -306,7 +339,7 @@ function drawNeuralConstellation(ctx: CanvasRenderingContext2D, frame: DrawFrame const distance = Math.hypot(dx, dy); const threshold = size * (frame.compact ? 0.19 : 0.16); if (distance > threshold) continue; - const route = frame.state === "processing" ? Math.sin(time * 4 + i * 0.8 + j) * 0.5 + 0.5 : 0.25; + const route = frame.state === "processing" ? Math.sin(frame.primaryPhase * 2.6 + i * 0.8 + j) * 0.5 + 0.5 : 0.25; const alpha = (1 - distance / threshold) * (0.15 + energy * 0.24 + route * 0.18); line(ctx, a.x, a.y, b.x, b.y, rgba(route > 0.7 ? accent : cool, alpha), 1 + route * 1.4); } @@ -314,7 +347,7 @@ function drawNeuralConstellation(ctx: CanvasRenderingContext2D, frame: DrawFrame for (let i = 0; i < points.length; i += 1) { const point = points[i]; - const wave = Math.max(0, Math.sin(time * (2.2 + motion * 2.8) - point.rank * 0.75)); + const wave = Math.max(0, Math.sin(frame.secondaryPhase * (1.4 + motion) - point.rank * 0.75)); const active = frame.state === "listening" ? wave : frame.state === "speaking" ? 1 - wave * 0.45 : frame.state === "processing" ? wave * 0.9 : 0.28; const radius = (frame.compact ? 2.1 : 3.1) + active * energy * (frame.immersive ? 5.8 : 3.8); glow(ctx, point.x, point.y, radius * 4.2, accent, active * 0.08); @@ -326,12 +359,11 @@ function drawNeuralConstellation(ctx: CanvasRenderingContext2D, frame: DrawFrame } function drawHologramWaveform(ctx: CanvasRenderingContext2D, frame: DrawFrame) { - const { width, height, time } = frame; + const { width, height } = frame; const cx = width / 2; const cy = height / 2; const size = Math.min(width, height); const energy = stateEnergy(frame); - const motion = frame.reducedMotion ? 0 : frame.motionLevel * frame.intensity; const accent = activeColor(frame); const cyan: Rgb = [62, 205, 226]; const violet: Rgb = [102, 116, 210]; @@ -351,7 +383,7 @@ function drawHologramWaveform(ctx: CanvasRenderingContext2D, frame: DrawFrame) { frame.state === "muted" ? size * 0.008 : size * (0.018 + energy * (frame.state === "speaking" ? 0.085 : 0.062)) * (1 - depth * 0.18); - const phase = time * (1.3 + motion * 2.5 + depth) + depth * 4; + const phase = frame.primaryPhase * (1.1 + depth * 0.45) + depth * 4; const layerColor = mix(layer % 2 ? accent : cyan, violet, depth * 0.55); const left = cx - bandWidth / 2; const right = cx + bandWidth / 2; @@ -363,7 +395,7 @@ function drawHologramWaveform(ctx: CanvasRenderingContext2D, frame: DrawFrame) { const envelope = Math.sin(progress * Math.PI); const carrier = Math.sin(progress * Math.PI * (4 + layer) + phase); const detail = Math.sin(progress * Math.PI * (11 + layer * 2) - phase * 0.74); - const compression = frame.state === "processing" ? Math.sign(Math.sin(progress * Math.PI * 18 + time * 7)) * 0.38 : 1; + const compression = frame.state === "processing" ? Math.sign(Math.sin(progress * Math.PI * 18 + frame.secondaryPhase * 4)) * 0.38 : 1; const yy = y + (carrier * 0.74 + detail * 0.26) * amplitude * envelope * compression; if (step === 0) ctx.moveTo(x, yy); else ctx.lineTo(x, yy); @@ -378,7 +410,7 @@ function drawHologramWaveform(ctx: CanvasRenderingContext2D, frame: DrawFrame) { line(ctx, left, y + size * 0.07, right, y + size * 0.07, rgba(pearl, 0.04 + (1 - depth) * 0.04), 1); } - const scanX = cx - bandWidth / 2 + ((time * (0.16 + motion * 0.28)) % 1) * bandWidth; + const scanX = cx - bandWidth / 2 + ((frame.tertiaryPhase * 0.12) % 1) * bandWidth; const scan = ctx.createLinearGradient(scanX - 18, cy, scanX + 18, cy); scan.addColorStop(0, rgba(cyan, 0)); scan.addColorStop(0.48, rgba(pearl, frame.state === "processing" ? 0.38 : 0.18)); @@ -389,18 +421,17 @@ function drawHologramWaveform(ctx: CanvasRenderingContext2D, frame: DrawFrame) { } function drawCommandCore(ctx: CanvasRenderingContext2D, frame: DrawFrame) { - const { width, height, time } = frame; + const { width, height } = frame; const cx = width / 2; const cy = height / 2; const size = Math.min(width, height); const energy = stateEnergy(frame); - const motion = frame.reducedMotion ? 0 : frame.motionLevel * frame.intensity; const accent = activeColor(frame); const amber: Rgb = [228, 174, 84]; const steel: Rgb = [30, 38, 48]; const chalk: Rgb = [235, 236, 228]; const r = size * (frame.immersive ? 0.35 : 0.33); - const sweep = time * (0.55 + motion * 1.8); + const sweep = frame.primaryPhase; drawBackground(ctx, frame, steel, true); @@ -414,7 +445,7 @@ function drawCommandCore(ctx: CanvasRenderingContext2D, frame: DrawFrame) { const hot = frame.state === "listening" ? i / 28 < frame.visualVolume : frame.state === "processing" - ? Math.sin(time * 8 + i * 0.6) > 0.35 + ? Math.sin(frame.secondaryPhase * 4 + i * 0.6) > 0.35 : frame.state === "speaking" ? Math.sin(angle - sweep) > 0.76 : false; @@ -468,18 +499,24 @@ const DRAWERS: Record; + frame: DrawFrameInput; + colorSources: { listening: string; speaking: string; processing: string }; }) { const canvasRef = useRef(null); const frameRef = useRef(frame); const styleRef = useRef(styleId); + const colorSourcesRef = useRef(colorSources); + const smoothedRef = useRef(null); + const phaseRef = useRef({ primary: 0, secondary: 0, tertiary: 0, lastNow: 0 }); useEffect(() => { frameRef.current = frame; styleRef.current = styleId; - }, [frame, styleId]); + colorSourcesRef.current = colorSources; + }, [colorSources, frame, styleId]); useEffect(() => { const canvas = canvasRef.current; @@ -504,11 +541,32 @@ function AgentIdentityCanvas({ const { width, height } = setupCanvas(canvas, ctx); const time = reducedMotion ? 0 : (now - start) / 1000; if (reducedMotion) start = now; + const delta = phaseRef.current.lastNow ? Math.min(0.05, (now - phaseRef.current.lastNow) / 1000) : 1 / 60; + phaseRef.current.lastNow = now; + const target = frameRef.current; + const previous = smoothedRef.current ?? target; + const sources = colorSourcesRef.current; + const smoothed: DrawFrameInput = { + ...target, + listening: parseRgb(sources.listening, canvas), + speaking: parseRgb(sources.speaking, canvas), + processing: parseRgb(sources.processing, canvas), + visualVolume: smoothLevel(previous.visualVolume, target.visualVolume, delta), + motionLevel: smoothLevel(previous.motionLevel, target.motionLevel, delta), + }; + smoothedRef.current = smoothed; + const speed = reducedMotion ? 0 : 0.38 + smoothed.motionLevel * 1.45 + smoothed.visualVolume * 0.36; + phaseRef.current.primary += speed * delta; + phaseRef.current.secondary += (speed * 0.63 + 0.18) * delta; + phaseRef.current.tertiary += (speed * 1.22 + 0.24) * delta; clear(ctx, width, height); const nextFrame = { - ...frameRef.current, + ...smoothed, reducedMotion, time, + primaryPhase: phaseRef.current.primary, + secondaryPhase: phaseRef.current.secondary, + tertiaryPhase: phaseRef.current.tertiary, width, height, }; @@ -575,7 +633,11 @@ export function AgentVisualizer({ className={`relative flex max-w-full items-center justify-center overflow-visible rounded-full select-none outline-none transition-transform duration-300 hover:scale-[1.01] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] ${sizeClass}`} style={{ "--tw-ring-color": rgba(accent, 0.5) } as CSSProperties} > - + ); } diff --git a/src/components/chat/voice-agent.tsx b/src/components/chat/voice-agent.tsx index 6f54709..dd883fe 100644 --- a/src/components/chat/voice-agent.tsx +++ b/src/components/chat/voice-agent.tsx @@ -170,8 +170,8 @@ export function VoiceAgent({ const normalized = Math.max(0, Math.min(1, Number.isFinite(nextLevel) ? nextLevel : 0)); const now = Date.now(); if ( - now - lastVolumeLevelAtRef.current < 50 && - Math.abs(normalized - lastVolumeLevelRef.current) < 0.035 + now - lastVolumeLevelAtRef.current < 24 && + Math.abs(normalized - lastVolumeLevelRef.current) < 0.012 ) { return; }