{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls"],"dependencies":["motion"],"description":"Voice dictation UI — tap the orb and a dithered WebGL ring swells with simulated speech amplitude while a faux transcript streams in word by word.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { VoiceDictator } from \"./voice-dictator\";\n\nconst Preview = () => <VoiceDictator />;\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/voice-dictator/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { FC } from \"react\";\nimport { motion } from \"motion/react\";\n\nconst TRANSCRIPT_LIBRARY = [\n  \"The warrior should be cloaked in obsidian armor with faint runes pulsing across each plate, wind tearing through the cape as petals scatter around the scene.\",\n  \"Let's add a soft volumetric glow behind the subject to carve out the silhouette and make the cliff edge feel like it's dissolving into the clouds below.\",\n  \"Scatter bright motes of dust around the character so it feels like the air is alive, and make the sunset bleed into saturated oranges and violets.\",\n  \"Bring in a shallow depth of field with a wide cinematic crop, keeping the focus on the warrior's profile while the background blurs into abstract shapes.\",\n  \"Introduce a faint aurora stretching across the horizon to hint at ancient magic awakening right behind the hero.\",\n] as const;\n\n/** Per-frame lerp factor pulling the rendered amplitude toward its target. */\nconst AMPLITUDE_SMOOTHING = 0.95;\n\ntype TimeoutHandle = ReturnType<typeof setTimeout> | null;\n\nconst pickTranscript = () => {\n  const index = Math.floor(Math.random() * TRANSCRIPT_LIBRARY.length);\n  return TRANSCRIPT_LIBRARY[index] ?? TRANSCRIPT_LIBRARY[0];\n};\n\nexport const VoiceDictator: FC = () => {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\n  const animationFrameRef = useRef<number | null>(null);\n\n  const amplitudeRef = useRef(0.08);\n  const targetAmplitudeRef = useRef(0.08);\n\n  const [isListening, setIsListening] = useState(false);\n  const [transcript, setTranscript] = useState(\n    \"Generate expressive prompts by speaking naturally. We will render a faux transcript here.\",\n  );\n\n  const listeningRef = useRef(false);\n  const scriptRef = useRef<string[]>([]);\n  const wordIndexRef = useRef(0);\n  const wordTimeoutRef = useRef<TimeoutHandle>(null);\n  const pulseTimeoutRef = useRef<TimeoutHandle>(null);\n\n  const initialiseWebGL = useCallback(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n\n    const gl = canvas.getContext(\"webgl\", {\n      antialias: false,\n      premultipliedAlpha: false,\n      preserveDrawingBuffer: false,\n    });\n\n    if (!gl) {\n      return;\n    }\n\n    const vertexShaderSource = `\n      attribute vec2 aPosition;\n\n      void main() {\n        gl_Position = vec4(aPosition, 0.0, 1.0);\n      }\n    `;\n\n    const fragmentShaderSource = `\n      precision highp float;\n\n      uniform float uTime;\n      uniform float uAmplitude;\n      uniform vec2 uResolution;\n\n      float bayerDither(vec2 coord) {\n        vec2 p = floor(mod(coord, 8.0));\n        float x = p.x;\n        float y = p.y;\n\n        float index =\n          1.0 * mod(x, 2.0) +\n          2.0 * mod(y, 2.0) +\n          4.0 * mod(floor(x / 2.0), 2.0) +\n          8.0 * mod(floor(y / 2.0), 2.0) +\n          16.0 * mod(floor(x / 4.0), 2.0) +\n          32.0 * mod(floor(y / 4.0), 2.0);\n\n        return (index + 0.5) / 64.0;\n      }\n\n      void main() {\n        vec2 normalized = gl_FragCoord.xy / uResolution;\n        vec2 uv = normalized * 2.0 - 1.0;\n        uv.x *= uResolution.x / uResolution.y;\n\n        float time = uTime * 0.5;\n        float amplitude = clamp(uAmplitude, 0.0, 1.2);\n\n        float radius = 0.21 + amplitude * 0.11 + sin(time * 0.9) * 0.012;\n        float thickness = 0.07 + amplitude * 0.05 + sin(time * 0.63) * 0.009;\n\n        float dist = length(uv);\n        float ring = smoothstep(radius + thickness, radius, dist) - smoothstep(radius, radius - thickness, dist);\n        float glow = exp(-14.0 * abs(dist - radius));\n        float halo = exp(-6.5 * dist * (1.0 + amplitude * 0.35));\n\n        float intensity = clamp(ring * 0.75 + glow * 0.5 + halo * 0.08, 0.0, 1.0);\n\n        float threshold = bayerDither(gl_FragCoord.xy);\n        float shade = step(threshold, intensity);\n\n        gl_FragColor = vec4(vec3(shade), 1.0);\n      }\n    `;\n\n    const createShader = (type: GLenum, source: string) => {\n      const shader = gl.createShader(type);\n      if (!shader) {\n        throw new Error(\"Unable to create shader\");\n      }\n      gl.shaderSource(shader, source);\n      gl.compileShader(shader);\n      const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);\n      if (!compiled) {\n        const info = gl.getShaderInfoLog(shader);\n        gl.deleteShader(shader);\n        throw new Error(info ?? \"Unknown shader compilation error\");\n      }\n      return shader;\n    };\n\n    const vertexShader = createShader(gl.VERTEX_SHADER, vertexShaderSource);\n    const fragmentShader = createShader(gl.FRAGMENT_SHADER, fragmentShaderSource);\n\n    const program = gl.createProgram();\n    if (!program) {\n      throw new Error(\"Unable to create WebGL program\");\n    }\n\n    gl.attachShader(program, vertexShader);\n    gl.attachShader(program, fragmentShader);\n    gl.linkProgram(program);\n\n    const linked = gl.getProgramParameter(program, gl.LINK_STATUS);\n    if (!linked) {\n      const info = gl.getProgramInfoLog(program);\n      gl.deleteProgram(program);\n      gl.deleteShader(vertexShader);\n      gl.deleteShader(fragmentShader);\n      throw new Error(info ?? \"Unknown WebGL linking error\");\n    }\n\n    gl.deleteShader(vertexShader);\n    gl.deleteShader(fragmentShader);\n\n    const buffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);\n    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);\n\n    const positionLocation = gl.getAttribLocation(program, \"aPosition\");\n    gl.enableVertexAttribArray(positionLocation);\n    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);\n\n    // eslint-disable-next-line react/hooks -- WebGL method, not a React hook.\n    gl.useProgram(program);\n    gl.disable(gl.DEPTH_TEST);\n    gl.disable(gl.CULL_FACE);\n    gl.enable(gl.BLEND);\n    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n\n    const uniforms = {\n      amplitude: gl.getUniformLocation(program, \"uAmplitude\"),\n      resolution: gl.getUniformLocation(program, \"uResolution\"),\n      time: gl.getUniformLocation(program, \"uTime\"),\n    };\n\n    // Sizing is driven by a ResizeObserver rather than read every frame, so the\n    // render loop never forces a synchronous layout.\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      const pixelRatio = window.devicePixelRatio || 1;\n      const width = Math.round(rect.width * pixelRatio);\n      const height = Math.round(rect.height * pixelRatio);\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width;\n        canvas.height = height;\n      }\n      gl.viewport(0, 0, width, height);\n    };\n\n    resize();\n    const resizeObserver = new ResizeObserver(resize);\n    resizeObserver.observe(canvas);\n\n    const render = (time: number) => {\n      animationFrameRef.current = requestAnimationFrame(render);\n\n      // eslint-disable-next-line react/hooks -- WebGL method, not a React hook.\n      gl.useProgram(program);\n      gl.clearColor(0, 0, 0, 1);\n      gl.clear(gl.COLOR_BUFFER_BIT);\n\n      const amplitude =\n        amplitudeRef.current +\n        (targetAmplitudeRef.current - amplitudeRef.current) * (1 - AMPLITUDE_SMOOTHING);\n      amplitudeRef.current = amplitude;\n\n      if (uniforms.time) {\n        gl.uniform1f(uniforms.time, time * 0.001);\n      }\n      if (uniforms.amplitude) {\n        gl.uniform1f(uniforms.amplitude, amplitude);\n      }\n      if (uniforms.resolution) {\n        gl.uniform2f(uniforms.resolution, gl.drawingBufferWidth, gl.drawingBufferHeight);\n      }\n\n      const button = buttonRef.current;\n      if (button) {\n        const scale = 1 + amplitude * 0.12;\n        const glow = 12 + amplitude * 80;\n        const opacity = 0.08 + amplitude * 0.22;\n        button.style.transform = `scale(${scale})`;\n        button.style.boxShadow = `0 0 ${glow.toFixed(1)}px rgba(255, 255, 255, ${opacity.toFixed(3)})`;\n      }\n\n      gl.drawArrays(gl.TRIANGLES, 0, 6);\n    };\n\n    render(0);\n\n    return () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current);\n      }\n      animationFrameRef.current = null;\n      resizeObserver.disconnect();\n      gl.deleteProgram(program);\n      gl.deleteBuffer(buffer);\n    };\n  }, []);\n\n  useEffect(() => {\n    try {\n      return initialiseWebGL();\n    } catch (error) {\n      console.error(error);\n    }\n  }, [initialiseWebGL]);\n\n  const stopDictation = useCallback(() => {\n    listeningRef.current = false;\n    setIsListening(false);\n  }, []);\n\n  useEffect(() => {\n    if (!isListening) {\n      if (pulseTimeoutRef.current) {\n        clearTimeout(pulseTimeoutRef.current);\n        pulseTimeoutRef.current = null;\n      }\n      targetAmplitudeRef.current = 0.06;\n      return;\n    }\n\n    const pump = () => {\n      if (!listeningRef.current) {\n        return;\n      }\n      targetAmplitudeRef.current = 0.25 + Math.random() * 0.7;\n      const delay = 120 + Math.random() * 80;\n      pulseTimeoutRef.current = setTimeout(pump, delay);\n    };\n\n    pump();\n\n    return () => {\n      if (pulseTimeoutRef.current) {\n        clearTimeout(pulseTimeoutRef.current);\n        pulseTimeoutRef.current = null;\n      }\n    };\n  }, [isListening]);\n\n  useEffect(() => {\n    if (!isListening) {\n      if (wordTimeoutRef.current) {\n        clearTimeout(wordTimeoutRef.current);\n        wordTimeoutRef.current = null;\n      }\n      return;\n    }\n\n    scriptRef.current = pickTranscript().split(\" \");\n    wordIndexRef.current = 0;\n\n    const deliver = () => {\n      if (!listeningRef.current) {\n        return;\n      }\n      if (wordIndexRef.current >= scriptRef.current.length) {\n        stopDictation();\n        return;\n      }\n\n      const nextWord = scriptRef.current[wordIndexRef.current];\n      if (nextWord) {\n        wordIndexRef.current += 1;\n        setTranscript((previous) => (previous.length > 0 ? `${previous} ${nextWord}` : nextWord));\n        targetAmplitudeRef.current = 0.32 + Math.random() * 0.6;\n      }\n\n      const delay = 160 + Math.random() * 200;\n      wordTimeoutRef.current = setTimeout(deliver, delay);\n    };\n\n    const initialDelay = 220 + Math.random() * 220;\n    wordTimeoutRef.current = setTimeout(deliver, initialDelay);\n\n    return () => {\n      if (wordTimeoutRef.current) {\n        clearTimeout(wordTimeoutRef.current);\n        wordTimeoutRef.current = null;\n      }\n    };\n  }, [isListening, stopDictation]);\n\n  const handleToggle = useCallback(() => {\n    if (listeningRef.current) {\n      stopDictation();\n      return;\n    }\n\n    listeningRef.current = true;\n    setTranscript(\"\");\n    setIsListening(true);\n  }, [stopDictation]);\n\n  return (\n    <div className=\"relative flex h-dvh w-dvw flex-col items-center justify-center overflow-hidden bg-black text-white\">\n      <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" />\n\n      <motion.button\n        ref={buttonRef}\n        type=\"button\"\n        aria-label={isListening ? \"Stop dictation\" : \"Start dictation\"}\n        aria-pressed={isListening}\n        onClick={handleToggle}\n        className=\"absolute top-1/2 left-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/80\"\n        animate={{\n          opacity: isListening ? 0.9 : 0.6,\n          scale: isListening ? [1, 1.1, 1] : 1,\n        }}\n        transition={{\n          opacity: {\n            duration: 0.3,\n            ease: \"easeInOut\",\n          },\n          scale: {\n            duration: 0.6,\n            ease: \"easeInOut\",\n            repeat: isListening ? Infinity : 0,\n          },\n        }}\n        whileHover={{ scale: 1.05 }}\n        whileTap={{ scale: 0.95 }}\n      />\n\n      <motion.p\n        className=\"absolute bottom-20 z-10 max-w-2xl text-base leading-7 text-pretty text-white/70 sm:text-lg\"\n        initial={{ opacity: 0, y: 20 }}\n        animate={{ opacity: 1, y: 0 }}\n        transition={{\n          duration: 0.4,\n          ease: \"easeInOut\",\n        }}\n      >\n        {transcript}\n      </motion.p>\n\n      <motion.div\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-48 bg-gradient-to-t from-black via-black/40 to-transparent\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        transition={{ duration: 0.5 }}\n      />\n    </div>\n  );\n};\n","path":"/voice-dictator.tsx","target":"uicapsule/voice-dictator/voice-dictator.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/voice-dictator","name":"voice-dictator","registryDependencies":[],"title":"Voice Dictator","type":"registry:block"}