{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "VoicePill-TS-TW",
	"title": "VoicePill",
	"description": "Mic button that swells into a capsule of live equalizer bars and a timer while recording.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "VoicePill/VoicePill.tsx",
			"content": "'use client';\n\nimport React, {\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent\n} from 'react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport { ArrowLeft01Icon, Mic01Icon } from '@hugeicons/core-free-icons';\nexport type VoicePillShape = 'pill' | 'rounded';\nexport type VoicePillMode = 'auto' | 'hold' | 'toggle';\nexport type VoicePillSource = 'simulated' | 'mic';\nexport type VoicePillStopReason =\n  | 'release'\n  | 'tap'\n  | 'key'\n  | 'escape'\n  | 'blur'\n  | 'disabled'\n  | 'mic-denied'\n  | 'cancel'\n  | 'unmount';\n\nexport interface VoicePillProps {\n  accentColor?: string;\n  iconColor?: string;\n  background?: string;\n  size?: number;\n  shape?: VoicePillShape;\n  reach?: number;\n  showTime?: boolean;\n  waveform?: boolean;\n  slideToCancel?: boolean;\n  cancelDistance?: number;\n  attack?: number;\n  release?: number;\n  sensitivity?: number;\n  floor?: number;\n  openDuration?: number;\n  pressScale?: number;\n  mode?: VoicePillMode;\n  holdAfter?: number;\n  reactive?: VoicePillSource;\n  disabled?: boolean;\n  ariaLabel?: string;\n  onStart?: (info: { source: VoicePillSource }) => void;\n  onStop?: (info: { reason: VoicePillStopReason; duration: number }) => void;\n  className?: string;\n}\n\ninterface AudioBits {\n  ctx: AudioContext;\n  stream?: MediaStream | null;\n  src?: MediaStreamAudioSourceNode | null;\n  analyser?: AnalyserNode | null;\n  buf?: Uint8Array<ArrayBuffer> | null;\n}\n\ninterface State {\n  listening: boolean;\n  pointerId: number | null;\n  ownPress: boolean;\n  downX: number;\n  sliding: boolean;\n  hist: number[];\n  tick: number;\n  acc: number;\n  downAt: number;\n  startedAt: number;\n  raf: number;\n  last: number;\n  env: number;\n  t0: number;\n  audio: AudioBits | null;\n}\n\ninterface Cfg {\n  attack: number;\n  release: number;\n  sensitivity: number;\n  floor: number;\n  mode: VoicePillMode;\n  holdAfter: number;\n  reactive: VoicePillSource;\n  showTime: boolean;\n  waveform: boolean;\n  slideToCancel: boolean;\n  cancelDistance: number;\n  accentColor: string;\n  onStart?: VoicePillProps['onStart'];\n  onStop?: VoicePillProps['onStop'];\n}\n\ndeclare global {\n  interface Window {\n    webkitAudioContext?: typeof AudioContext;\n  }\n}\n\nconst LOOP = 4.8;\nconst SYLLABLES = [\n  [0.1, 0.16, 0.9],\n  [0.3, 0.12, 0.7],\n  [0.5, 0.2, 1],\n  [0.95, 0.14, 0.8],\n  [1.15, 0.1, 0.6],\n  [1.3, 0.22, 0.95],\n  [1.9, 0.16, 0.85],\n  [2.12, 0.12, 0.7],\n  [2.3, 0.18, 0.9],\n  [2.55, 0.1, 0.5],\n  [3.05, 0.24, 1],\n  [3.4, 0.12, 0.75],\n  [3.6, 0.16, 0.9]\n];\nconst MIC_BINS = [\n  [1, 4],\n  [4, 11],\n  [11, 33]\n];\nconst MIC_GAIN = 2.2;\nconst DT_MAX = 0.05;\nconst SLIDE_MIN = 4;\nconst WAVE_EVERY = 4;\nconst WAVE_MAX = 80;\n\nconst simulatedLevel = (t: number) => {\n  const u = t % LOOP;\n  let a = 0.06;\n  for (const [s, d, p] of SYLLABLES) {\n    const x = (u - s) / d;\n    if (x >= 0 && x <= 1) a = Math.max(a, p * 0.5 * (1 - Math.cos(2 * Math.PI * x)));\n  }\n  return a * (0.7 + 0.3 * Math.abs(Math.sin(2 * Math.PI * 7.1 * u)));\n};\nconst micLevel = (analyser: AnalyserNode, buf: Uint8Array<ArrayBuffer>) => {\n  analyser.getByteFrequencyData(buf);\n  let total = 0;\n  for (const [lo, hi] of MIC_BINS) {\n    let s = 0;\n    for (let i = lo; i < hi; i += 1) s += buf[i];\n    total += s / ((hi - lo) * 255);\n  }\n  return (total / MIC_BINS.length) * MIC_GAIN;\n};\nconst drawWave = (s: State, canvas: HTMLCanvasElement, level: number, color: string, floor: number) => {\n  const dpr = Math.min(2, window.devicePixelRatio || 1);\n  const rect = canvas.getBoundingClientRect();\n  const W = Math.max(1, Math.round(rect.width * dpr));\n  const H = Math.max(1, Math.round(rect.height * dpr));\n  if (canvas.width !== W || canvas.height !== H) {\n    canvas.width = W;\n    canvas.height = H;\n  }\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return;\n  s.acc = Math.max(s.acc, level);\n  s.tick = (s.tick + 1) % WAVE_EVERY;\n  if (s.tick === 0) {\n    s.hist.push(s.acc);\n    s.acc = 0;\n    if (s.hist.length > WAVE_MAX) s.hist.shift();\n  }\n  const bw = 2 * dpr;\n  const step = 3 * dpr;\n  const shift = (s.tick / WAVE_EVERY) * step;\n  ctx.clearRect(0, 0, W, H);\n  ctx.fillStyle = color;\n  for (let i = 0; i < s.hist.length; i += 1) {\n    const v = s.hist[s.hist.length - 1 - i];\n    const x = W - (i + 1) * step - shift;\n    if (x + bw < 0) break;\n    const h = Math.max(bw, (floor + (1 - floor) * v) * H);\n    const t = Math.min(1, Math.max(0, (x + bw / 2) / (W * 0.55)));\n    const fade = t * t * (3 - 2 * t);\n    ctx.globalAlpha = (0.35 + 0.65 * v) * fade;\n    ctx.beginPath();\n    ctx.roundRect(x, (H - h) / 2, bw, h, bw / 2);\n    ctx.fill();\n  }\n  ctx.globalAlpha = 1;\n};\nconst clock = (ms: number) => {\n  const s = Math.floor(ms / 1000);\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;\n};\n\nconst openMic = async (s: State) => {\n  const Ctx = window.AudioContext || window.webkitAudioContext;\n  if (!Ctx || !navigator.mediaDevices?.getUserMedia) throw new Error('unsupported');\n  s.audio ??= { ctx: new Ctx() };\n  const a = s.audio;\n  if (a.ctx.state === 'suspended') a.ctx.resume();\n  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n  if (!s.listening) {\n    stream.getTracks().forEach(t => t.stop());\n    return;\n  }\n  a.stream = stream;\n  a.src = a.ctx.createMediaStreamSource(stream);\n  a.analyser = a.ctx.createAnalyser();\n  a.analyser.fftSize = 256;\n  a.analyser.smoothingTimeConstant = 0;\n  a.src.connect(a.analyser);\n  a.buf = new Uint8Array(a.analyser.frequencyBinCount);\n};\nconst closeMic = (s: State) => {\n  const a = s.audio;\n  if (!a?.stream) return;\n  a.stream.getTracks().forEach(t => t.stop());\n  a.src?.disconnect();\n  a.stream = null;\n  a.src = null;\n  a.analyser = null;\n  a.buf = null;\n};\n\nconst VoicePill: React.FC<VoicePillProps> = ({\n  accentColor = '#f5f5f5',\n  iconColor = '#a1a1aa',\n  background = '#27272a',\n  size = 28,\n  shape = 'pill',\n  reach = 8,\n  showTime = true,\n  waveform = true,\n  slideToCancel = true,\n  cancelDistance = 64,\n  attack = 40,\n  release = 240,\n  sensitivity = 1,\n  floor = 0.1,\n  openDuration = 200,\n  pressScale = 0.95,\n  mode = 'auto',\n  holdAfter = 300,\n  reactive = 'simulated',\n  disabled = false,\n  ariaLabel = 'Dictate',\n  onStart,\n  onStop,\n  className = ''\n}) => {\n  const [listening, setListening] = useState(false);\n  const [pressed, setPressed] = useState(false);\n  const [input, setInput] = useState('pointer');\n  const timeRef = useRef<HTMLSpanElement>(null);\n  const rootRef = useRef<HTMLButtonElement>(null);\n  const waveRef = useRef<HTMLCanvasElement>(null);\n  const st = useRef<State>({\n    listening: false,\n    pointerId: null,\n    ownPress: false,\n    downX: 0,\n    sliding: false,\n    hist: [],\n    tick: 0,\n    acc: 0,\n    downAt: 0,\n    startedAt: 0,\n    raf: 0,\n    last: 0,\n    env: 0,\n    t0: 0,\n    audio: null\n  });\n  const cfg = useRef<Cfg>({} as Cfg);\n  cfg.current = {\n    attack,\n    release,\n    sensitivity,\n    floor,\n    mode,\n    holdAfter,\n    reactive,\n    showTime,\n    waveform,\n    slideToCancel,\n    cancelDistance,\n    accentColor,\n    onStart,\n    onStop\n  };\n\n  const frame = (now: number) => {\n    const s = st.current;\n    const c = cfg.current;\n    const dt = Math.min((now - s.last) / 1000, DT_MAX);\n    s.last = now;\n    let target = 0;\n    if (s.listening) {\n      if (s.audio?.analyser) target = micLevel(s.audio.analyser, s.audio.buf as Uint8Array<ArrayBuffer>);\n      else if (c.reactive !== 'mic') target = simulatedLevel((now - s.t0) / 1000);\n    }\n    target = Math.min(1, target * c.sensitivity);\n    const tau = Math.max(1, target > s.env ? c.attack : c.release) / 1000;\n    s.env += (target - s.env) * (1 - Math.exp(-dt / tau));\n    if (s.listening && c.showTime && timeRef.current) {\n      const text = clock(now - s.startedAt);\n      if (timeRef.current.textContent !== text) timeRef.current.textContent = text;\n    }\n    if (s.listening && c.waveform && waveRef.current) drawWave(s, waveRef.current, s.env, c.accentColor, c.floor);\n    s.raf = s.listening ? requestAnimationFrame(frame) : 0;\n  };\n\n  const begin = (kind: 'pointer' | 'key') => {\n    const s = st.current;\n    const c = cfg.current;\n    if (s.listening || disabled) return;\n    s.listening = true;\n    s.hist = [];\n    s.tick = 0;\n    s.acc = 0;\n    s.env = 0;\n    s.startedAt = performance.now();\n    s.t0 = s.startedAt;\n    s.last = s.startedAt;\n    if (timeRef.current) timeRef.current.textContent = '0:00';\n    setListening(true);\n    setInput(kind);\n    if (!s.raf) s.raf = requestAnimationFrame(frame);\n    c.onStart?.({ source: c.reactive });\n    if (c.reactive === 'mic') openMic(s).catch(() => end('mic-denied'));\n  };\n  const end = (reason: VoicePillStopReason) => {\n    const s = st.current;\n    const c = cfg.current;\n    if (!s.listening) return;\n    s.listening = false;\n    closeMic(s);\n    setListening(false);\n    setInput(reason === 'key' || reason === 'escape' ? 'key' : 'pointer');\n    c.onStop?.({ reason, duration: Math.round(performance.now() - s.startedAt) });\n  };\n\n  const settleSlide = () => {\n    const s = st.current;\n    const root = rootRef.current;\n    s.sliding = false;\n    if (!root) return;\n    delete root.dataset.sliding;\n    root.style.setProperty('--vp-slide', '0px');\n    root.style.setProperty('--vp-cancel', '0');\n  };\n  const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {\n    const s = st.current;\n    const c = cfg.current;\n    const root = rootRef.current;\n    if (!root || s.pointerId !== e.pointerId || !c.slideToCancel || !s.listening || !s.ownPress) return;\n    const dx = e.clientX - s.downX;\n    if (!s.sliding && dx > -SLIDE_MIN) return;\n    s.sliding = true;\n    root.dataset.sliding = '';\n    const pull = Math.min(c.cancelDistance + 24, Math.max(0, -dx));\n    root.style.setProperty('--vp-slide', `${-pull}px`);\n    const progress = Math.min(1, pull / c.cancelDistance);\n    root.style.setProperty('--vp-cancel', progress.toFixed(3));\n    if (progress >= 1) {\n      settleSlide();\n      end('cancel');\n    }\n  };\n  const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {\n    const s = st.current;\n    if (disabled || e.button !== 0 || !e.isPrimary || s.pointerId !== null) return;\n    s.pointerId = e.pointerId;\n    s.downX = e.clientX;\n    s.downAt = performance.now();\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    setPressed(true);\n    s.ownPress = !s.listening;\n    if (!s.listening) begin('pointer');\n  };\n  const onPointerUp = (e: PointerEvent<HTMLButtonElement>) => {\n    const s = st.current;\n    const c = cfg.current;\n    if (e.pointerId !== s.pointerId) return;\n    s.pointerId = null;\n    setPressed(false);\n    if (s.sliding) settleSlide();\n    try {\n      if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);\n    } catch {}\n    if (!s.listening) return;\n    const held = performance.now() - s.downAt;\n    const isHold = c.mode === 'hold' || (c.mode === 'auto' && held >= c.holdAfter);\n    if (s.ownPress) {\n      if (isHold) end('release');\n    } else {\n      end(held < c.holdAfter ? 'tap' : 'release');\n    }\n  };\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === 'Escape') {\n      end('escape');\n      return;\n    }\n    if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {\n      e.preventDefault();\n      if (st.current.listening) end('key');\n      else begin('key');\n    }\n  };\n  const onClick = (e: MouseEvent<HTMLButtonElement>) => {\n    if (e.detail === 0 && st.current.pointerId === null && !(e.nativeEvent as globalThis.PointerEvent).pointerType) {\n      if (st.current.listening) end('key');\n      else begin('key');\n    }\n  };\n\n  useEffect(() => {\n    if (!pressed) return undefined;\n    const stop = () => end('blur');\n    const onVis = () => {\n      if (document.hidden) stop();\n    };\n    window.addEventListener('blur', stop);\n    document.addEventListener('visibilitychange', onVis);\n    return () => {\n      window.removeEventListener('blur', stop);\n      document.removeEventListener('visibilitychange', onVis);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [pressed]);\n  useEffect(() => {\n    if (disabled) end('disabled');\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [disabled]);\n  useEffect(() => {\n    const s = st.current;\n    return () => {\n      end('unmount');\n      cancelAnimationFrame(s.raf);\n      s.audio?.ctx.close();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const radius = shape === 'rounded' ? Math.round(size * 0.29) : size / 2;\n  const hit = Math.max(0, Math.min(10, (44 - size) / 2));\n  const timeSize = Math.max(10, Math.round(size * 0.36));\n  const clockW = showTime ? Math.round(timeSize * 2.5) + 4 : 0;\n  const waveW = waveform ? Math.round(size * 1.9) : 0;\n  const extra = clockW + waveW;\n\n  return (\n    <button\n      type=\"button\"\n      disabled={disabled}\n      aria-label={ariaLabel}\n      aria-pressed={listening}\n      className={`group relative isolate m-0 inline-grid cursor-pointer touch-none place-items-center border-0 bg-transparent p-0 outline-none select-none [width:var(--vp-size)] [height:var(--vp-size)] [border-radius:var(--vp-radius)] [color:var(--vp-icon)] [font-family:inherit] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1),color_150ms_ease] before:absolute before:[inset:calc(-1*var(--vp-hit))] before:content-[''] data-[state=listening]:z-[1] data-[state=listening]:[color:var(--vp-accent)] data-[pressed]:[transform:scale(var(--vp-press))] disabled:pointer-events-none disabled:cursor-default disabled:opacity-55 motion-reduce:[transform:none]! motion-reduce:[transition:color_150ms_ease]${className ? ` ${className}` : ''}`}\n      data-state={listening ? 'listening' : 'idle'}\n      data-pressed={pressed ? '' : undefined}\n      data-input={input}\n      data-time={showTime ? '' : undefined}\n      ref={rootRef}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n      onLostPointerCapture={onPointerUp}\n      onKeyDown={onKeyDown}\n      onClick={onClick}\n      onContextMenu={e => e.preventDefault()}\n      style={\n        {\n          '--vp-accent': accentColor,\n          '--vp-icon': iconColor,\n          '--vp-bg': background,\n          '--vp-size': `${size}px`,\n          '--vp-radius': `${radius}px`,\n          '--vp-reach': `${reach}px`,\n          '--vp-extra': `${extra}px`,\n          '--vp-clock-w': `${clockW}px`,\n          '--vp-wave-w': `${waveW}px`,\n          '--vp-stop': `${Math.round(size * 0.32)}px`,\n          '--vp-icon-size': `${Math.round(size * 0.54)}px`,\n          '--vp-time-size': `${timeSize}px`,\n          '--vp-open': `${openDuration}ms`,\n          '--vp-press': pressScale,\n          '--vp-hit': `${hit}px`\n        } as CSSProperties\n      }\n    >\n      <span\n        className=\"pointer-events-none absolute [inset:0_0_0_calc(-1*(var(--vp-reach)+var(--vp-extra)))] [border-radius:var(--vp-radius)] [background:var(--vp-bg)] [clip-path:inset(0_0_0_calc(var(--vp-reach)+var(--vp-extra))_round_var(--vp-radius))] [transition:clip-path_var(--vp-open)_cubic-bezier(0.23,1,0.32,1),background-color_150ms_ease] [@media(hover:hover)_and_(pointer:fine)]:group-enabled:group-data-[state=idle]:group-hover:[background:color-mix(in_srgb,var(--vp-icon)_10%,var(--vp-bg))] group-data-[state=listening]:[clip-path:inset(0_0_round_var(--vp-radius))] group-data-[input=key]:[transition-duration:0ms] motion-reduce:[transition:background-color_150ms_ease]\"\n        aria-hidden=\"true\"\n      />\n      {waveform ? (\n        <canvas\n          ref={waveRef}\n          className=\"pointer-events-none absolute top-[18%] h-[64%] opacity-0 [right:calc(100%+var(--vp-clock-w))] [width:var(--vp-wave-w)] [filter:blur(2px)] [transform:translateX(var(--vp-slide))] [transition:opacity_200ms_ease,filter_200ms_ease,transform_240ms_cubic-bezier(0.23,1,0.32,1)] group-data-[state=listening]:[opacity:calc(1-var(--vp-cancel))] group-data-[state=listening]:[filter:blur(0)] group-data-[sliding]:[transition-property:opacity,filter] motion-reduce:[filter:none]! motion-reduce:[transition-property:opacity]\"\n          aria-hidden=\"true\"\n        />\n      ) : null}\n      {slideToCancel ? (\n        <span\n          className=\"pointer-events-none absolute inset-y-0 right-full inline-flex items-center justify-center gap-[3px] leading-none font-medium [width:var(--vp-extra)] [color:var(--vp-accent)] [font-size:var(--vp-time-size)] [opacity:var(--vp-cancel)]\"\n          aria-hidden=\"true\"\n        >\n          <HugeiconsIcon icon={ArrowLeft01Icon} size={12} strokeWidth={2.2} />\n          <span>Cancel</span>\n        </span>\n      ) : null}\n      {showTime ? (\n        <span\n          ref={timeRef}\n          className=\"pointer-events-none absolute inset-y-0 right-full box-border grid place-items-center leading-none font-medium tabular-nums opacity-0 [width:var(--vp-clock-w)] [color:var(--vp-accent)] [font-size:var(--vp-time-size)] [filter:blur(2px)] [transform:translateX(var(--vp-slide))] [transition:opacity_200ms_ease,filter_200ms_ease,transform_240ms_cubic-bezier(0.23,1,0.32,1)] group-data-[state=listening]:[opacity:calc(1-var(--vp-cancel))] group-data-[state=listening]:[filter:blur(0)] group-data-[sliding]:[transition-property:opacity,filter] group-data-[input=key]:[transition-duration:0ms] motion-reduce:[filter:none]! motion-reduce:[transition-property:opacity]\"\n          aria-hidden=\"true\"\n        >\n          0:00\n        </span>\n      ) : null}\n      <span className=\"relative grid place-items-center [width:var(--vp-size)] [height:var(--vp-size)] [transform:translateX(var(--vp-slide))] [transition:transform_240ms_cubic-bezier(0.23,1,0.32,1)] group-data-[sliding]:[transition-property:opacity,filter] motion-reduce:[filter:none]! motion-reduce:[transition-property:opacity]\">\n        <span className=\"inline-flex [grid-area:1/1] [transition:opacity_200ms_ease,filter_200ms_ease,transform_200ms_cubic-bezier(0.23,1,0.32,1)] group-data-[state=listening]:opacity-0 group-data-[state=listening]:[filter:blur(2px)] group-data-[state=listening]:[transform:scale(0.7)] group-data-[input=key]:[transition-duration:0ms] motion-reduce:[filter:none]! motion-reduce:[transform:none]! motion-reduce:[transition:opacity_200ms_ease]\">\n          <HugeiconsIcon icon={Mic01Icon} size={Math.round(size * 0.54)} strokeWidth={2} />\n        </span>\n        <span\n          className=\"bg-current opacity-0 [grid-area:1/1] [width:var(--vp-stop)] [height:var(--vp-stop)] [border-radius:22%] [filter:blur(2px)] [transform:scale(0.6)] [transition:opacity_200ms_ease,filter_200ms_ease,transform_200ms_cubic-bezier(0.23,1,0.32,1)] group-data-[state=listening]:opacity-100 group-data-[state=listening]:[filter:blur(0)] group-data-[state=listening]:[transform:scale(1)] group-data-[input=key]:[transition-duration:0ms] motion-reduce:[filter:none]! motion-reduce:[transform:none]! motion-reduce:[transition:opacity_200ms_ease]\"\n          aria-hidden=\"true\"\n        />\n      </span>\n    </button>\n  );\n};\n\nexport default VoicePill;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}