Fazekit

Code

"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>
  );
}

More in AI

View all →