{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Shared AI state contract, per-state motion presets and microphone amplitude hooks for SmoothUI AI components.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport {\n  type MotionValue,\n  useAnimationFrame,\n  useMotionValue,\n} from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\n/**\n * The single state contract shared by every SmoothUI AI component.\n *\n * Passing the same value to an orb, a prompt input and a tool card makes the\n * whole surface move as one organism instead of a set of independent widgets.\n */\nexport type AIState =\n  | \"idle\"\n  | \"listening\"\n  | \"thinking\"\n  | \"streaming\"\n  | \"done\"\n  | \"error\";\n\n/**\n * A behavioural hint each component expresses **in its own material**.\n *\n * Deliberately not an overlay. Bolting one shared graphic onto every orb makes\n * four different materials look like the same widget wearing a costume, and it\n * pushes literal iconography (a checkmark, a warning ring) onto pieces that are\n * decorative by nature. Instead: a shader warps, a canvas ring loses harmonics,\n * a character changes expression. Same vocabulary, different flesh.\n */\nexport type AIStateMotif =\n  | \"breathe\"\n  | \"receive\"\n  | \"scan\"\n  | \"pulse\"\n  | \"ping\"\n  | \"fault\";\n\n/** Semantic accent applied on top of the component's own palette. */\nexport type AIStateAccent = \"success\" | \"danger\" | null;\n\n/** Motion parameters a component reads to render a given {@link AIState}. */\nexport type AIStateMotion = {\n  /** Semantic colour override, so status is carried by hue and not only motion. */\n  accent: AIStateAccent;\n  /** Outer bloom strength, 0–1. */\n  glow: number;\n  /** Palette hue rotation in degrees. Small shifts read as a mood change. */\n  hueRotate: number;\n  /** Overall motion energy, 0–1. Scales ambient loops and displacement. */\n  intensity: number;\n  /** Behavioural hint; each component expresses it in its own material. */\n  motif: AIStateMotif;\n  /** Seconds between discrete pulses, for rhythmic behaviour. */\n  pulseSeconds: number;\n  /**\n   * How far a shader's domain warp pushes the field, 0–1. Low values read as a\n   * calm surface; high values churn without the silhouette growing.\n   */\n  turbulence: number;\n  /** Revolutions per second of the noise field — the slow tumble. */\n  tumble: number;\n  /** How much external amplitude reaches the surface, 0 = ignore it. */\n  reactivity: number;\n  /** Chroma multiplier. Below 1 desaturates. */\n  saturation: number;\n  /** Resting scale of the surface, 1 = no change. */\n  scale: number;\n  /** Ambient loop speed multiplier, 1 = the component's base tempo. */\n  speed: number;\n};\n\n/**\n * Per-state presets.\n *\n * Deliberate choices worth keeping:\n * - `thinking` has `scale: 1` — internal churn only, so layout stays calm while\n *   the model works. Growing the surface here makes pages feel unstable.\n * - `error` desaturates instead of growing, so it reads as a state change\n *   rather than an attention grab.\n * - `done` overshoots once; components are expected to settle back to `idle`.\n * - Each state owns a distinct `motif`, so the six states are told apart by\n *   what moves, not by how fast it moves.\n */\nexport const AI_STATE_MOTION: Record<AIState, AIStateMotion> = {\n  done: {\n    accent: \"success\",\n    glow: 0.7,\n    hueRotate: 0,\n    intensity: 0.4,\n    motif: \"ping\",\n    pulseSeconds: 0.65,\n    reactivity: 0,\n    saturation: 1,\n    scale: 1.1,\n    speed: 0.8,\n    // The field settles almost flat — stillness is what reads as \"finished\".\n    tumble: 0.02,\n    turbulence: 0.08,\n  },\n  error: {\n    accent: \"danger\",\n    glow: 0.25,\n    hueRotate: 0,\n    intensity: 0.5,\n    motif: \"fault\",\n    pulseSeconds: 0.9,\n    reactivity: 0,\n    saturation: 0.3,\n    scale: 0.96,\n    speed: 1,\n    // Tumble stalls while turbulence stays mid: the surface twitches in place\n    // instead of flowing, which is what a fault feels like.\n    tumble: 0,\n    turbulence: 0.55,\n  },\n  idle: {\n    accent: null,\n    glow: 0.15,\n    hueRotate: 0,\n    intensity: 0.3,\n    motif: \"breathe\",\n    pulseSeconds: 4.5,\n    reactivity: 0,\n    saturation: 0.75,\n    scale: 0.94,\n    speed: 0.6,\n    tumble: 0.012,\n    turbulence: 0.14,\n  },\n  listening: {\n    accent: null,\n    glow: 0.6,\n    hueRotate: 0,\n    intensity: 0.75,\n    motif: \"receive\",\n    pulseSeconds: 1.6,\n    reactivity: 1,\n    saturation: 1.05,\n    scale: 1.06,\n    speed: 1,\n    tumble: 0.03,\n    turbulence: 0.42,\n  },\n  streaming: {\n    accent: null,\n    glow: 0.45,\n    hueRotate: -10,\n    intensity: 0.6,\n    motif: \"pulse\",\n    pulseSeconds: 1.25,\n    reactivity: 0.6,\n    saturation: 1,\n    scale: 1.02,\n    speed: 1.4,\n    tumble: 0.06,\n    turbulence: 0.5,\n  },\n  thinking: {\n    accent: null,\n    glow: 0.35,\n    hueRotate: 18,\n    intensity: 1,\n    motif: \"scan\",\n    pulseSeconds: 1.1,\n    reactivity: 0.15,\n    saturation: 1,\n    scale: 1,\n    speed: 2.4,\n    // High churn, unchanged silhouette: the work is visible without the orb\n    // growing and unsettling the layout around it.\n    tumble: 0.14,\n    turbulence: 0.95,\n  },\n};\n\n/** Semantic accents. Deliberately not tokens — orbs render outside a theme. */\nexport const AI_ACCENT_COLORS: Record<\"success\" | \"danger\", string> = {\n  danger: \"oklch(63% 0.21 25)\",\n  success: \"oklch(72% 0.17 150)\",\n};\n\n/**\n * Colour used by a state's overlay motif: the semantic accent when the state\n * has one, otherwise the component's own secondary colour.\n */\nexport const getAIStateAccentColor = (\n  state: AIState | undefined,\n  fallback: string\n): string => {\n  const accent = AI_STATE_MOTION[state ?? \"idle\"]?.accent;\n  return accent ? AI_ACCENT_COLORS[accent] : fallback;\n};\n\n/** Motion preset for a state, falling back to `idle` for unknown values. */\nexport const getAIStateMotion = (state: AIState | undefined): AIStateMotion =>\n  AI_STATE_MOTION[state ?? \"idle\"] ?? AI_STATE_MOTION.idle;\n\n/**\n * Amplitude accepted by every reactive AI component.\n *\n * A `MotionValue` is the preferred form: it updates outside React, so a 60fps\n * audio signal never triggers a re-render.\n */\nexport type AIAmplitude = number | MotionValue<number> | undefined;\n\nconst isMotionValue = (value: AIAmplitude): value is MotionValue<number> =>\n  typeof value === \"object\" && value !== null && \"get\" in value;\n\n/**\n * Normalises the `amplitude` prop into a stable `MotionValue<number>` so\n * component internals only deal with one shape.\n */\nexport const useAmplitudeValue = (\n  amplitude: AIAmplitude\n): MotionValue<number> => {\n  const fallback = useMotionValue(0);\n  const numeric = typeof amplitude === \"number\" ? amplitude : null;\n\n  useEffect(() => {\n    if (numeric !== null) {\n      fallback.set(numeric);\n    }\n  }, [numeric, fallback]);\n\n  return isMotionValue(amplitude) ? amplitude : fallback;\n};\n\nexport type AudioAmplitudeStatus =\n  | \"idle\"\n  | \"requesting\"\n  | \"active\"\n  | \"denied\"\n  | \"unsupported\";\n\nexport type UseAudioAmplitudeOptions = {\n  /** Request microphone access as soon as the hook mounts. */\n  autoStart?: boolean;\n  /**\n   * Envelope smoothing, 0–1. Higher is smoother and lazier; the default keeps\n   * attack snappy so an orb reacts on the first syllable.\n   */\n  smoothing?: number;\n  /** FFT size handed to the analyser node. Must be a power of two. */\n  fftSize?: number;\n};\n\nexport type UseAudioAmplitudeResult = {\n  /** Smoothed RMS level, 0–1, as a `MotionValue`. */\n  amplitude: MotionValue<number>;\n  status: AudioAmplitudeStatus;\n  start: () => Promise<void>;\n  stop: () => void;\n};\n\nconst DEFAULT_SMOOTHING = 0.55;\nconst DEFAULT_FFT_SIZE = 512;\n/** Raw RMS rarely exceeds ~0.3 for speech, so normalise into a usable 0–1. */\nconst RMS_TO_UNIT = 3.2;\n/** Attack is faster than release so peaks land immediately and decay gently. */\nconst ATTACK_FACTOR = 0.35;\n\n/**\n * Reads microphone loudness as a 0–1 `MotionValue`.\n *\n * SSR-safe, permission-aware, and silent on failure — a denied prompt leaves\n * the amplitude at 0 so the consuming component simply falls back to its\n * ambient animation.\n */\nexport const useAudioAmplitude = (\n  options: UseAudioAmplitudeOptions = {}\n): UseAudioAmplitudeResult => {\n  const {\n    autoStart = false,\n    smoothing = DEFAULT_SMOOTHING,\n    fftSize = DEFAULT_FFT_SIZE,\n  } = options;\n\n  const amplitude = useMotionValue(0);\n  const [status, setStatus] = useState<AudioAmplitudeStatus>(\"idle\");\n\n  const contextRef = useRef<AudioContext | null>(null);\n  const streamRef = useRef<MediaStream | null>(null);\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const bufferRef = useRef<Float32Array<ArrayBuffer> | null>(null);\n\n  const stop = useCallback(() => {\n    for (const track of streamRef.current?.getTracks() ?? []) {\n      track.stop();\n    }\n    streamRef.current = null;\n    analyserRef.current = null;\n    bufferRef.current = null;\n    contextRef.current?.close();\n    contextRef.current = null;\n    amplitude.set(0);\n    setStatus(\"idle\");\n  }, [amplitude]);\n\n  const start = useCallback(async () => {\n    if (analyserRef.current) {\n      return;\n    }\n\n    const AudioContextCtor =\n      typeof window === \"undefined\"\n        ? undefined\n        : (window.AudioContext ??\n          (window as unknown as { webkitAudioContext?: typeof AudioContext })\n            .webkitAudioContext);\n\n    if (!(AudioContextCtor && navigator.mediaDevices?.getUserMedia)) {\n      setStatus(\"unsupported\");\n      return;\n    }\n\n    setStatus(\"requesting\");\n\n    try {\n      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      const context = new AudioContextCtor();\n      const analyser = context.createAnalyser();\n      analyser.fftSize = fftSize;\n      context.createMediaStreamSource(stream).connect(analyser);\n\n      streamRef.current = stream;\n      contextRef.current = context;\n      analyserRef.current = analyser;\n      bufferRef.current = new Float32Array(analyser.fftSize);\n      setStatus(\"active\");\n    } catch {\n      setStatus(\"denied\");\n    }\n  }, [fftSize]);\n\n  useEffect(() => {\n    if (autoStart) {\n      start();\n    }\n    return stop;\n  }, [autoStart, start, stop]);\n\n  useAnimationFrame(() => {\n    const analyser = analyserRef.current;\n    const buffer = bufferRef.current;\n    if (!(analyser && buffer)) {\n      return;\n    }\n\n    analyser.getFloatTimeDomainData(buffer);\n\n    let sumOfSquares = 0;\n    for (const sample of buffer) {\n      sumOfSquares += sample * sample;\n    }\n    const rms = Math.sqrt(sumOfSquares / buffer.length);\n    const target = Math.min(1, rms * RMS_TO_UNIT);\n\n    const previous = amplitude.get();\n    const factor = target > previous ? smoothing * ATTACK_FACTOR : smoothing;\n    amplitude.set(previous + (target - previous) * (1 - factor));\n  });\n\n  return { amplitude, start, status, stop };\n};\n\n/**\n * Amplitude generator for demos, docs and previews — no microphone involved.\n *\n * Produces a plausible speech-like envelope whose energy follows the current\n * {@link AIState}, so every example can show the reactive behaviour without\n * asking the visitor for permissions.\n */\nexport const useSimulatedAmplitude = (\n  state: AIState = \"idle\"\n): MotionValue<number> => {\n  const amplitude = useMotionValue(0);\n  const motion = getAIStateMotion(state);\n\n  useAnimationFrame((time) => {\n    const t = time / 1000;\n    // Three detuned sines read as organic; a single sine reads as a metronome.\n    const envelope =\n      0.5 +\n      0.3 * Math.sin(t * 2.1 * motion.speed) +\n      0.14 * Math.sin(t * 5.3 * motion.speed + 1.7) +\n      0.06 * Math.sin(t * 11.7 * motion.speed + 0.4);\n\n    amplitude.set(Math.min(1, Math.max(0, envelope * motion.intensity)));\n  });\n\n  return amplitude;\n};\n","path":"index.tsx","target":"components/smoothui/ai-core/index.tsx","type":"registry:ui"}],"name":"ai-core","registryDependencies":[],"title":"Ai Core","type":"registry:ui"}