Skip to content

Set a timer for 10 minutes, and remind me to water the plants.

Voice edge

A WebGL glow that rises from one edge of a surface as you speak, and sweeps side to side while the reply is worked out.

Uses
WebGL
Web Audio

Code

"use client";

import * as React from "react";

import { cn } from "@manikrana/ui/lib/utils";
import type { VoiceEdgePalette, VoiceEdgePaletteName } from "./palettes";
import {
  MIC_MIN_PEAK,
  buildStaticAudioBins,
  defaultAudioResponseCurve,
  type VoiceEdgeResponseCurve,
} from "./audio-response";
import {
  createVoiceEdgeShader,
  emptyAudioBins,
  resolveAudioPalette,
  syncAudioBorderClip,
  type AudioBorderGeometryInput,
  type VoiceEdgeController,
  type VoiceEdgeSide,
} from "./shader";

type VoiceEdgeAnalyser = Pick<AnalyserNode, "frequencyBinCount" | "getByteFrequencyData">;

type VoiceEdgeProps = Omit<React.ComponentProps<"div">, "children"> & {
  active?: boolean;
  analyser?: VoiceEdgeAnalyser;
  borderBrightness?: number;
  borderRadius?: number;
  borderWidth?: number;
  children: React.ReactNode;
  deviceId?: string;
  fftSize?: number;
  idleLevel?: number;
  innerGlowBrightness?: number;
  innerGlowHeight?: number;
  noiseFloor?: number;
  onError?: (error: Error) => void;
  onStreamEnd?: () => void;
  onStreamReady?: (stream: MediaStream) => void;
  palette?: VoiceEdgePaletteName | VoiceEdgePalette;
  processing?: boolean;
  responseCurve?: VoiceEdgeResponseCurve;
  sensitivity?: number;
  side?: VoiceEdgeSide;
  smoothingTimeConstant?: number;
  updateRate?: number;
};

function VoiceEdge({
  active = false,
  analyser,
  borderBrightness = 1,
  borderRadius,
  borderWidth = 1,
  children,
  className,
  deviceId,
  fftSize = 256,
  idleLevel = 0.015,
  innerGlowBrightness = 1,
  innerGlowHeight = 1,
  noiseFloor = 0.08,
  onError,
  onStreamEnd,
  onStreamReady,
  palette = "prism",
  processing = false,
  ref,
  responseCurve = defaultAudioResponseCurve,
  sensitivity = 0.9,
  side = "bottom",
  smoothingTimeConstant = 0.8,
  style,
  updateRate = 16,
  ...props
}: VoiceEdgeProps) {
  const audioContextRef = React.useRef<AudioContext | null>(null);
  const analyserRef = React.useRef<VoiceEdgeAnalyser | null>(null);
  const canvasHostRef = React.useRef<HTMLSpanElement | null>(null);
  const containerRef = React.useRef<HTMLDivElement | null>(null);
  const controllerRef = React.useRef<VoiceEdgeController | null>(null);
  const lastUpdateRef = React.useRef(0);
  const peakRef = React.useRef(MIC_MIN_PEAK);
  const rafRef = React.useRef(0);
  const streamRef = React.useRef<MediaStream | null>(null);
  const audioParamsRef = React.useRef({
    noiseFloor,
    responseCurve,
    sensitivity,
  });
  const captureActive = active && !processing;
  const geometryInputRef = React.useRef<AudioBorderGeometryInput>({
    borderRadius,
    borderWidth,
  });
  const shaderOptionsRef = React.useRef({
    active: captureActive,
    borderBrightness,
    idleLevel,
    innerGlowBrightness,
    innerGlowHeight,
    palette,
    processing,
    side,
  });

  geometryInputRef.current = {
    borderRadius,
    borderWidth,
  };
  shaderOptionsRef.current = {
    active: captureActive,
    borderBrightness,
    idleLevel,
    innerGlowBrightness,
    innerGlowHeight,
    palette,
    processing,
    side,
  };
  audioParamsRef.current = {
    noiseFloor,
    responseCurve,
    sensitivity,
  };

  React.useEffect(() => {
    const controller = controllerRef.current;

    if (!controller) return;

    controller.setActive(captureActive);
    controller.setBorderBrightness(borderBrightness);
    controller.setIdleLevel(idleLevel);
    controller.setInnerGlowBrightness(innerGlowBrightness);
    controller.setInnerGlowHeight(innerGlowHeight);
    controller.setPalette(resolveAudioPalette(palette));
    controller.setProcessing(processing);
    controller.setSide(side);
  }, [
    borderBrightness,
    captureActive,
    idleLevel,
    innerGlowBrightness,
    innerGlowHeight,
    palette,
    processing,
    side,
  ]);

  React.useEffect(() => {
    const container = containerRef.current;
    const host = canvasHostRef.current;

    if (!container || !host) return undefined;

    const target = container.firstElementChild;

    if (!(target instanceof HTMLElement)) return undefined;

    const canvas = document.createElement("canvas");

    canvas.setAttribute("aria-hidden", "true");
    canvas.className = "pointer-events-none absolute inset-0 block size-full";

    syncAudioBorderClip(host, target, geometryInputRef.current);
    host.appendChild(canvas);

    const controller = createVoiceEdgeShader(
      canvas,
      target,
      geometryInputRef,
      shaderOptionsRef.current,
    );

    if (!controller) {
      canvas.remove();
      return undefined;
    }

    controllerRef.current = controller;

    return () => {
      controller.destroy();
      controllerRef.current = null;
      canvas.remove();
    };
  }, []);

  React.useEffect(() => {
    if (captureActive || processing) return;

    controllerRef.current?.setAudioBins(emptyAudioBins);
  }, [captureActive, processing]);

  React.useEffect(() => {
    const host = canvasHostRef.current;
    const target = containerRef.current?.firstElementChild;

    if (!host || !(target instanceof HTMLElement)) return;

    syncAudioBorderClip(host, target, geometryInputRef.current);
  });

  React.useEffect(() => {
    if (!captureActive) {
      controllerRef.current?.setActive(false);

      if (rafRef.current) {
        window.cancelAnimationFrame(rafRef.current);
        rafRef.current = 0;
      }

      peakRef.current = MIC_MIN_PEAK;

      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => track.stop());
        streamRef.current = null;
        onStreamEnd?.();
      }

      if (audioContextRef.current && audioContextRef.current.state !== "closed") {
        audioContextRef.current.close();
        audioContextRef.current = null;
      }

      analyserRef.current = null;
      return undefined;
    }

    let cancelled = false;

    const updateAudio = (currentTime: number) => {
      if (cancelled) return;

      const analyserNode = analyserRef.current;

      if (analyserNode && currentTime - lastUpdateRef.current > updateRate) {
        lastUpdateRef.current = currentTime;

        const dataArray = new Uint8Array(analyserNode.frequencyBinCount);
        const audioParams = audioParamsRef.current;

        analyserNode.getByteFrequencyData(dataArray);
        const frame = buildStaticAudioBins(
          dataArray,
          audioParams.sensitivity,
          audioParams.noiseFloor,
          audioParams.responseCurve,
          peakRef.current,
        );

        peakRef.current = frame.peak;
        controllerRef.current?.setAudioBins(frame.bins);
      }

      rafRef.current = window.requestAnimationFrame(updateAudio);
    };

    const setupMicrophone = async () => {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: deviceId
            ? {
                autoGainControl: false,
                deviceId: { exact: deviceId },
                echoCancellation: false,
                noiseSuppression: false,
              }
            : {
                autoGainControl: false,
                echoCancellation: false,
                noiseSuppression: false,
              },
        });

        if (cancelled) {
          stream.getTracks().forEach((track) => track.stop());
          return;
        }

        streamRef.current = stream;
        onStreamReady?.(stream);

        const AudioContextConstructor =
          window.AudioContext ||
          (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
        const audioContext = new AudioContextConstructor();
        const analyser = audioContext.createAnalyser();

        analyser.fftSize = fftSize;
        analyser.smoothingTimeConstant = smoothingTimeConstant;

        const source = audioContext.createMediaStreamSource(stream);
        source.connect(analyser);

        audioContextRef.current = audioContext;
        analyserRef.current = analyser;
        controllerRef.current?.setActive(true);

        rafRef.current = window.requestAnimationFrame(updateAudio);
      } catch (error) {
        onError?.(error as Error);
      }
    };

    if (analyser) {
      analyserRef.current = analyser;
      controllerRef.current?.setActive(true);
      rafRef.current = window.requestAnimationFrame(updateAudio);
    } else {
      setupMicrophone();
    }

    return () => {
      cancelled = true;

      if (rafRef.current) {
        window.cancelAnimationFrame(rafRef.current);
        rafRef.current = 0;
      }

      peakRef.current = MIC_MIN_PEAK;

      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => track.stop());
        streamRef.current = null;
        onStreamEnd?.();
      }

      if (audioContextRef.current && audioContextRef.current.state !== "closed") {
        audioContextRef.current.close();
        audioContextRef.current = null;
      }

      analyserRef.current = null;
    };
  }, [
    analyser,
    captureActive,
    deviceId,
    fftSize,
    onError,
    onStreamEnd,
    onStreamReady,
    smoothingTimeConstant,
    updateRate,
  ]);

  const setRefs = React.useCallback(
    (node: HTMLDivElement | null) => {
      containerRef.current = node;
      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [ref],
  );

  return (
    <div
      {...props}
      data-slot="voice-edge"
      ref={setRefs}
      className={cn("relative isolate inline-block overflow-hidden", className)}
      style={style}
    >
      {children}
      <span
        aria-hidden="true"
        ref={canvasHostRef}
        className="pointer-events-none absolute inset-0 z-20 block overflow-hidden"
      />
    </div>
  );
}

export { VoiceEdge };
export type { VoiceEdgeAnalyser, VoiceEdgeProps, VoiceEdgeResponseCurve, VoiceEdgeSide };
export type { VoiceEdgeResponseCurvePoint } from "./audio-response";
export type { VoiceEdgePalette, VoiceEdgePaletteName } from "./palettes";