"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type OrbState = "idle" | "listening" | "thinking" | "speaking";
export type MicStatus = "off" | "requesting" | "active" | "denied" | "unsupported";
export interface AIVoiceOrbProps {
/** Visual state of the assistant. */
state?: OrbState;
/** Diameter in px (the canvas adds room for the glow). */
size?: number;
/** Try to read the real microphone level while listening. Falls back to a simulated level. */
micEnabled?: boolean;
/** External 0–1 level (e.g. from your TTS output). Overrides mic/simulation when set. */
level?: number;
/** Three hex colours: primary, secondary, highlight. */
colors?: [string, string, string];
onMicStatusChange?: (status: MicStatus) => void;
/** Accessible label prefix; the state is appended. */
label?: string;
className?: string;
}
const STATE_TEXT: Record<OrbState, string> = {
idle: "idle",
listening: "listening",
thinking: "thinking",
speaking: "speaking",
};
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h.padEnd(6, "0");
const n = parseInt(full.slice(0, 6), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${Math.max(0, Math.min(1, a))})`;
/** Pauses work when the element is off-screen or the tab is hidden. */
function useActive(ref: React.RefObject<HTMLElement | null>) {
const [active, setActive] = React.useState(true);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
let visible = true;
const update = () => setActive(visible && !document.hidden);
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
update();
});
io.observe(el);
document.addEventListener("visibilitychange", update);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", update);
};
}, [ref]);
return active;
}
export function AIVoiceOrb({
state = "idle",
size = 220,
micEnabled = false,
level,
colors = ["#7c3aed", "#22d3ee", "#f472b6"],
onMicStatusChange,
label = "Voice assistant",
className,
}: AIVoiceOrbProps) {
const wrapRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const active = useActive(wrapRef);
const reduce = useReducedMotion() ?? false;
// Live values read by the animation loop without restarting it.
const live = React.useRef({ state, level, colors, reduce });
const micLevel = React.useRef<number | null>(null);
const statusCb = React.useRef(onMicStatusChange);
React.useLayoutEffect(() => {
live.current = { state, level, colors, reduce };
statusCb.current = onMicStatusChange;
});
// Microphone analyser.
React.useEffect(() => {
if (!micEnabled) {
micLevel.current = null;
statusCb.current?.("off");
return;
}
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
statusCb.current?.("unsupported");
return;
}
let cancelled = false;
let stream: MediaStream | null = null;
let ctx: AudioContext | null = null;
let raf = 0;
statusCb.current?.("requesting");
navigator.mediaDevices
.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } })
.then((s) => {
if (cancelled) {
s.getTracks().forEach((t) => t.stop());
return;
}
stream = s;
ctx = new AudioContext();
const src = ctx.createMediaStreamSource(s);
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
src.connect(analyser);
const buf = new Uint8Array(analyser.fftSize);
const tick = () => {
analyser.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128;
sum += v * v;
}
micLevel.current = Math.min(1, Math.sqrt(sum / buf.length) * 5);
raf = requestAnimationFrame(tick);
};
tick();
statusCb.current?.("active");
})
.catch(() => {
if (!cancelled) statusCb.current?.("denied");
});
return () => {
cancelled = true;
cancelAnimationFrame(raf);
stream?.getTracks().forEach((t) => t.stop());
ctx?.close().catch(() => {});
micLevel.current = null;
};
}, [micEnabled]);
// Render loop.
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !active) return;
const g = canvas.getContext("2d");
if (!g) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const box = size * 1.5;
canvas.width = Math.round(box * dpr);
canvas.height = Math.round(box * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
let raf = 0;
let last = performance.now();
let t = 0;
const s = { energy: 0.1, spin: 0, think: 0, listen: 0, speak: 0 };
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const { state: st, level: ext, colors: cols, reduce: rm } = live.current;
const speed = rm ? 0.35 : 1;
t += dt * speed;
// Target level per state.
let target: number;
if (ext !== undefined) target = ext;
else if (st === "listening")
target = micLevel.current ?? 0.25 + 0.25 * Math.abs(Math.sin(t * 2.7) * Math.sin(t * 1.3 + 0.6));
else if (st === "speaking")
target = 0.35 + 0.5 * Math.abs(Math.sin(t * 8.5) * Math.sin(t * 2.1 + 1) * (0.6 + 0.4 * Math.sin(t * 0.7)));
else if (st === "thinking") target = 0.25;
else target = 0.08;
if (rm) target *= 0.5;
const k = 1 - Math.exp(-dt * 10);
s.energy += (target - s.energy) * k;
const ease = (key: "think" | "listen" | "speak", on: boolean) => {
s[key] += ((on ? 1 : 0) - s[key]) * (1 - Math.exp(-dt * 4));
};
ease("think", st === "thinking");
ease("listen", st === "listening");
ease("speak", st === "speaking");
s.spin += dt * speed * (0.4 + s.think * 2.6);
const c1 = hexToRgb(cols[0]);
const c2 = hexToRgb(cols[1]);
const c3 = hexToRgb(cols[2]);
const cx = box / 2;
const cy = box / 2;
const R = size * 0.34 * (1 + Math.sin(t * 1.4) * 0.015 * (1 - s.speak) + s.energy * 0.06);
g.clearRect(0, 0, box, box);
// Outer glow.
const glow = g.createRadialGradient(cx, cy, R * 0.6, cx, cy, R * 2.05);
glow.addColorStop(0, rgba(c1, 0.45 + s.energy * 0.3));
glow.addColorStop(0.45, rgba(c2, 0.12 + s.energy * 0.12));
glow.addColorStop(1, rgba(c2, 0));
g.fillStyle = glow;
g.fillRect(0, 0, box, box);
// Listening ripples.
if (s.listen > 0.01) {
for (let i = 0; i < 3; i++) {
const p = (t * 0.5 + i / 3) % 1;
g.beginPath();
g.arc(cx, cy, R * (1.05 + p * (0.55 + s.energy * 0.4)), 0, Math.PI * 2);
g.strokeStyle = rgba(c2, (1 - p) * 0.45 * s.listen);
g.lineWidth = 1.5;
g.stroke();
}
}
// Deformed blobs.
g.globalCompositeOperation = "lighter";
const amp = 0.018 + s.energy * (0.07 + s.speak * 0.1 + s.listen * 0.05);
const blobs: [number, [number, number, number], number][] = [
[0, c1, 0.55],
[2.1, c2, 0.45],
[4.2, c3, 0.4],
];
for (const [phase, col, alpha] of blobs) {
g.beginPath();
const N = 72;
for (let i = 0; i <= N; i++) {
const a = (i / N) * Math.PI * 2;
const n =
Math.sin(a * 3 + t * 1.7 + phase) * 0.5 +
Math.sin(a * 5 - t * 2.3 + phase * 1.3) * 0.3 +
Math.sin(a * 2 + t * 0.9 - phase) * 0.4;
const r = R * (1 + amp * n);
const x = cx + Math.cos(a + s.spin * 0.3) * r;
const y = cy + Math.sin(a + s.spin * 0.3) * r;
if (i === 0) g.moveTo(x, y);
else g.lineTo(x, y);
}
g.closePath();
const grad = g.createRadialGradient(cx - R * 0.3, cy - R * 0.35, R * 0.1, cx, cy, R * 1.1);
grad.addColorStop(0, rgba(col, alpha));
grad.addColorStop(1, rgba(col, alpha * 0.35));
g.fillStyle = grad;
g.fill();
}
g.globalCompositeOperation = "source-over";
// Core body.
g.save();
g.beginPath();
g.arc(cx, cy, R * 0.94, 0, Math.PI * 2);
g.clip();
const core = g.createRadialGradient(cx, cy + R * 0.3, R * 0.1, cx, cy, R);
core.addColorStop(0, rgba(c1, 0.95));
core.addColorStop(1, rgba([Math.round(c1[0] * 0.25), Math.round(c1[1] * 0.2), Math.round(c1[2] * 0.45)], 1));
g.fillStyle = core;
g.fillRect(cx - R, cy - R, R * 2, R * 2);
g.globalCompositeOperation = "lighter";
const swirl: [number, [number, number, number], number][] = [
[0, c2, 0.7],
[Math.PI * 0.66, c3, 0.55],
[Math.PI * 1.33, c1, 0.6],
];
for (const [off, col, a] of swirl) {
const ang = s.spin + off;
const d = R * (0.35 + 0.12 * Math.sin(t * 1.3 + off));
const x = cx + Math.cos(ang) * d;
const y = cy + Math.sin(ang) * d * 0.8;
const rr = R * (0.55 + s.energy * 0.3);
const sg = g.createRadialGradient(x, y, 0, x, y, rr);
sg.addColorStop(0, rgba(col, a * (0.6 + s.energy * 0.6)));
sg.addColorStop(1, rgba(col, 0));
g.fillStyle = sg;
g.fillRect(cx - R, cy - R, R * 2, R * 2);
}
g.globalCompositeOperation = "source-over";
// Specular highlight.
const hl = g.createRadialGradient(cx - R * 0.35, cy - R * 0.45, 0, cx - R * 0.35, cy - R * 0.45, R * 0.6);
hl.addColorStop(0, "rgba(255,255,255,0.55)");
hl.addColorStop(1, "rgba(255,255,255,0)");
g.fillStyle = hl;
g.fillRect(cx - R, cy - R, R * 2, R * 2);
g.restore();
// Rim.
g.beginPath();
g.arc(cx, cy, R * 0.94, 0, Math.PI * 2);
g.strokeStyle = "rgba(255,255,255,0.25)";
g.lineWidth = 1;
g.stroke();
// Thinking orbit arcs.
if (s.think > 0.01) {
g.lineCap = "round";
for (let i = 0; i < 3; i++) {
const start = s.spin * (1 + i * 0.35) + (i * Math.PI * 2) / 3;
g.beginPath();
g.arc(cx, cy, R * (1.16 + i * 0.09), start, start + 0.9 + 0.3 * Math.sin(t * 2 + i));
g.strokeStyle = rgba(i === 1 ? c3 : c2, 0.75 * s.think);
g.lineWidth = 2.5 - i * 0.5;
g.stroke();
}
}
// Speaking equaliser ring.
if (s.speak > 0.01) {
const bars = 48;
for (let i = 0; i < bars; i++) {
const a = (i / bars) * Math.PI * 2 - Math.PI / 2;
const v = Math.abs(Math.sin(i * 1.7 + t * 6) * Math.sin(i * 0.45 - t * 3.2)) * s.energy;
const r0 = R * 1.08;
const r1 = r0 + R * (0.04 + v * 0.35);
g.beginPath();
g.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0);
g.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
g.strokeStyle = rgba(i % 2 ? c2 : c3, 0.65 * s.speak);
g.lineWidth = 2;
g.lineCap = "round";
g.stroke();
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [active, size]);
return (
<div
ref={wrapRef}
role="img"
aria-label={`${label}: ${STATE_TEXT[state]}`}
className={cn("relative grid shrink-0 place-items-center", className)}
style={{ width: size * 1.5, maxWidth: "100%", aspectRatio: "1 / 1" }}
>
<canvas ref={canvasRef} aria-hidden className="size-full" />
</div>
);
}