{"$schema":"https://ui.shadcn.com/schema/registry-item.json","categories":["voice","realtime","agent","ai-sdk","webrtc","vercel"],"description":"A canvas-rendered voice ring button for AI SDK-compatible Agora WebRTC voice agents in Vercel and React apps.","docs":"Install with `npx shadcn@latest add https://agora-realtime-agent.vercel.app/r/voice-ring-button.json`, then import `VoiceRingButton` and `useVoiceMeter` from your shadcn aliases. Homepage: [agora-realtime-agent.vercel.app](https://agora-realtime-agent.vercel.app/?utm_source=shadcn-registry&utm_medium=registry-item-docs&utm_campaign=agora_voice_agents). Vercel AI SDK guide: https://agora-realtime-agent.vercel.app/docs/use-with-vercel-ai-sdk. Official Agora docs: https://docs.agora.io/en/","files":[{"content":"'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { CSSProperties, ReactElement } from 'react';\n\nexport const AI_BAR_COUNT = 72;\nexport const HUMAN_BAR_COUNT = 72;\n\nexport type VoiceRingState = 'connecting' | 'disconnected' | 'interrupted' | 'listening' | 'thinking';\n\nexport type Rgb = readonly [number, number, number];\nexport type BarPalette = {\n  end: Rgb;\n  middle: Rgb;\n  start: Rgb;\n};\n\nexport type WaveRingConfig = {\n  barCount: number;\n  barWidth: number;\n  direction: 'in' | 'out';\n  excitement: number;\n  frequencyDirection: 1 | -1;\n  maxBarHeight: number;\n  minBarHeight: number;\n  opacity: number;\n  palette: BarPalette;\n  radius: number;\n  sampleStride: number;\n};\n\nexport type VoiceRingConfig = {\n  ai: WaveRingConfig;\n  human: WaveRingConfig;\n  levelFloor: number;\n  ringGap: number;\n  ringLineWidth: number;\n  transitionMs: number;\n};\n\nexport type VoiceRingConfigInput = {\n  ai?: Partial<WaveRingConfig>;\n  human?: Partial<WaveRingConfig>;\n  levelFloor?: number;\n  ringGap?: number;\n  ringLineWidth?: number;\n  transitionMs?: number;\n};\n\nexport type VoiceRingButtonProps = {\n  ariaLabel?: string;\n  aiLevels: number[] | null;\n  canvasClassName?: string;\n  className?: string;\n  config?: VoiceRingConfigInput;\n  disabled?: boolean;\n  humanLevels: number[] | null;\n  onClick: () => void;\n  state: VoiceRingState;\n  style?: CSSProperties;\n  testId?: string;\n};\n\nconst RING_CENTER = 200;\nconst VOICE_CANVAS_SIZE = 400;\nconst WAVE_BLEND_PRIMARY_WEIGHT = 0.7;\nconst WAVE_ROTATION_SPEED = 7.8;\n\nexport const DEFAULT_VOICE_RING_CONFIG: VoiceRingConfig = {\n  ai: {\n    barCount: AI_BAR_COUNT,\n    barWidth: 2,\n    direction: 'out',\n    excitement: 2.35,\n    frequencyDirection: 1,\n    maxBarHeight: 66,\n    minBarHeight: 11,\n    opacity: 0.98,\n    palette: {\n      end: [76, 238, 255],\n      middle: [82, 164, 255],\n      start: [154, 118, 255],\n    },\n    radius: 62,\n    sampleStride: 1,\n  },\n  human: {\n    barCount: HUMAN_BAR_COUNT,\n    barWidth: 2,\n    direction: 'in',\n    excitement: 1.7,\n    frequencyDirection: 1,\n    maxBarHeight: 36,\n    minBarHeight: 6,\n    opacity: 0.94,\n    palette: {\n      end: [154, 118, 255],\n      middle: [236, 95, 180],\n      start: [255, 184, 96],\n    },\n    radius: 62,\n    sampleStride: 1,\n  },\n  levelFloor: 0.12,\n  ringGap: 0,\n  ringLineWidth: 2,\n  transitionMs: 360,\n};\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction mergeVoiceRingConfig(input?: VoiceRingConfigInput): VoiceRingConfig {\n  const outerRadius = input?.ai?.radius ?? DEFAULT_VOICE_RING_CONFIG.ai.radius;\n  const ringGap = input?.ringGap ?? DEFAULT_VOICE_RING_CONFIG.ringGap;\n  const humanRadius = input?.human?.radius ?? Math.max(48, outerRadius - ringGap);\n\n  return {\n    ai: {\n      ...DEFAULT_VOICE_RING_CONFIG.ai,\n      ...input?.ai,\n      radius: outerRadius,\n    },\n    human: {\n      ...DEFAULT_VOICE_RING_CONFIG.human,\n      ...input?.human,\n      radius: humanRadius,\n    },\n    levelFloor: input?.levelFloor ?? DEFAULT_VOICE_RING_CONFIG.levelFloor,\n    ringGap,\n    ringLineWidth: input?.ringLineWidth ?? DEFAULT_VOICE_RING_CONFIG.ringLineWidth,\n    transitionMs: input?.transitionMs ?? DEFAULT_VOICE_RING_CONFIG.transitionMs,\n  };\n}\n\nfunction makeFlatLevels(count: number, levelFloor: number): number[] {\n  return Array.from({ length: count }, () => levelFloor);\n}\n\nfunction rgba(color: Rgb, alpha: number): string {\n  return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;\n}\n\nfunction joinClassNames(...classNames: Array<string | undefined>): string {\n  return classNames.filter(Boolean).join(' ');\n}\n\nfunction mixRgb(start: Rgb, end: Rgb, amount: number): Rgb {\n  return [\n    Math.round(start[0] + (end[0] - start[0]) * amount),\n    Math.round(start[1] + (end[1] - start[1]) * amount),\n    Math.round(start[2] + (end[2] - start[2]) * amount),\n  ];\n}\n\nfunction circularLevelAt(levels: number[], index: number, levelFloor: number): number {\n  return levels[((index % levels.length) + levels.length) % levels.length] ?? levelFloor;\n}\n\nfunction cubicInterpolate(leftOuter: number, left: number, right: number, rightOuter: number, amount: number): number {\n  const squared = amount * amount;\n  const cubed = squared * amount;\n  return left + 0.5 * amount * (right - leftOuter)\n    + squared * (leftOuter - 2.5 * left + 2 * right - 0.5 * rightOuter)\n    + cubed * (-0.5 * leftOuter + 1.5 * left - 1.5 * right + 0.5 * rightOuter);\n}\n\nfunction sampleCircularLevel(levels: number[], position: number, levelFloor: number): number {\n  if (levels.length === 0) return levelFloor;\n  const wrapped = ((position % levels.length) + levels.length) % levels.length;\n  const leftIndex = Math.floor(wrapped);\n  const amount = wrapped - leftIndex;\n  return clamp(cubicInterpolate(\n    circularLevelAt(levels, leftIndex - 1, levelFloor),\n    circularLevelAt(levels, leftIndex, levelFloor),\n    circularLevelAt(levels, leftIndex + 1, levelFloor),\n    circularLevelAt(levels, leftIndex + 2, levelFloor),\n    amount,\n  ), levelFloor, 1);\n}\n\nfunction easeInOutCubic(value: number): number {\n  return value < 0.5 ? 4 * value * value * value : 1 - ((-2 * value + 2) ** 3) / 2;\n}\n\nfunction mixLevels(from: number[], to: number[], amount: number, levelFloor: number): number[] {\n  const count = Math.max(from.length, to.length);\n  return Array.from({ length: count }, (_, index) => {\n    const left = from[index % from.length] ?? levelFloor;\n    const right = to[index % to.length] ?? left;\n    return left + (right - left) * amount;\n  });\n}\n\nfunction waveSamplePosition(input: {\n  index: number;\n  levelCount: number;\n  ring: WaveRingConfig;\n  sampleOffset: number;\n}) {\n  const progress = input.ring.barCount <= 1 ? 0 : input.index / (input.ring.barCount - 1);\n  const directedProgress = input.ring.frequencyDirection === 1 ? progress : 1 - progress;\n  const centeredProgress = 0.5 + (directedProgress - 0.5) * input.ring.sampleStride;\n  return centeredProgress * Math.max(0, input.levelCount - 1) + input.sampleOffset;\n}\n\nfunction equivalentSamplePosition(position: number, sourceCount: number, targetCount: number): number {\n  const sourceSpan = Math.max(1, sourceCount - 1);\n  return (position / sourceSpan) * Math.max(0, targetCount - 1);\n}\n\nfunction blendWaveLevels(input: {\n  levelFloor: number;\n  primaryLevels: number[];\n  primaryWeight: number;\n  ring: WaveRingConfig;\n  sampleOffset: number;\n  secondaryLevels: number[];\n}): number[] {\n  return Array.from({ length: input.ring.barCount }, (_, index) => {\n    const primaryPosition = waveSamplePosition({\n      index,\n      levelCount: input.primaryLevels.length,\n      ring: input.ring,\n      sampleOffset: input.sampleOffset,\n    });\n    const primaryLevel = sampleCircularLevel(input.primaryLevels, primaryPosition, input.levelFloor);\n    const secondaryLevel = sampleCircularLevel(\n      input.secondaryLevels,\n      equivalentSamplePosition(primaryPosition, input.primaryLevels.length, input.secondaryLevels.length),\n      input.levelFloor,\n    );\n    return input.primaryWeight * primaryLevel + (1 - input.primaryWeight) * secondaryLevel;\n  });\n}\n\nfunction levelAboveFloor(level: number, levelFloor: number): number {\n  return clamp((level - levelFloor) / (1 - levelFloor), 0, 1);\n}\n\nfunction exciteLevel(level: number, excitement: number): number {\n  const amplified = clamp((level - 0.035) * excitement, 0, 1);\n  return amplified * amplified * (3 - 2 * amplified);\n}\n\nfunction voiceIntensityFromLevels(levels: number[] | null, levelFloor: number): number {\n  if (!levels || levels.length === 0) return 0;\n  const sorted = [...levels].sort((left, right) => right - left);\n  const sampleCount = Math.max(1, Math.ceil(sorted.length * 0.22));\n  const topAverage = sorted.slice(0, sampleCount).reduce((sum, level) => sum + level, 0) / sampleCount;\n  return levelAboveFloor(topAverage, levelFloor);\n}\n\nfunction stateTarget(state: VoiceRingState): number {\n  switch (state) {\n    case 'connecting':\n      return 0.48;\n    case 'listening':\n      return 1;\n    case 'thinking':\n      return 0.72;\n    case 'interrupted':\n      return 1;\n    case 'disconnected':\n    default:\n      return 0;\n  }\n}\n\nfunction stateScale(state: VoiceRingState): number {\n  switch (state) {\n    case 'connecting':\n      return 0.98;\n    case 'listening':\n      return 1;\n    case 'thinking':\n      return 1.015;\n    case 'interrupted':\n      return 1.035;\n    case 'disconnected':\n    default:\n      return 0.94;\n  }\n}\n\nfunction idleRingPalette(config: VoiceRingConfig): BarPalette {\n  return {\n    end: config.ai.palette.end,\n    middle: config.ai.palette.start,\n    start: config.human.palette.start,\n  };\n}\n\nfunction drawRingCircle(ctx: CanvasRenderingContext2D, input: {\n  amount: number;\n  config: VoiceRingConfig;\n  palette: BarPalette;\n  radius: number;\n}) {\n  if (input.amount <= 0.001) return;\n  const gradient = ctx.createLinearGradient(\n    RING_CENTER - input.radius,\n    RING_CENTER - input.radius,\n    RING_CENTER + input.radius,\n    RING_CENTER + input.radius,\n  );\n  const alpha = input.amount * 0.58;\n  gradient.addColorStop(0, rgba(input.palette.start, alpha));\n  gradient.addColorStop(0.5, rgba(input.palette.middle, alpha + 0.04));\n  gradient.addColorStop(1, rgba(input.palette.end, alpha));\n\n  ctx.save();\n  ctx.globalCompositeOperation = 'source-over';\n  ctx.lineCap = 'round';\n  ctx.lineWidth = input.config.ringLineWidth;\n  ctx.strokeStyle = gradient;\n  ctx.beginPath();\n  ctx.arc(RING_CENTER, RING_CENTER, input.radius, 0, Math.PI * 2);\n  ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawStateArc(ctx: CanvasRenderingContext2D, input: {\n  clock: number;\n  config: VoiceRingConfig;\n  palette: BarPalette;\n  radius: number;\n  state: VoiceRingState;\n  stateAmount: number;\n}) {\n  if (input.state !== 'connecting' && input.state !== 'thinking') return;\n  const sweep = input.state === 'connecting' ? 0.42 : 0.24;\n  const speed = input.state === 'connecting' ? 2.8 : 1.1;\n  const start = input.clock * speed;\n  const color = input.state === 'connecting' ? input.palette.middle : input.palette.end;\n  ctx.save();\n  ctx.lineCap = 'round';\n  ctx.lineWidth = input.config.ringLineWidth + (input.state === 'connecting' ? 1.2 : 0.6);\n  ctx.strokeStyle = rgba(color, 0.26 * input.stateAmount);\n  ctx.beginPath();\n  ctx.arc(RING_CENTER, RING_CENTER, input.radius + 2, start, start + Math.PI * sweep);\n  ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawInterruptBurst(ctx: CanvasRenderingContext2D, input: {\n  clock: number;\n  config: VoiceRingConfig;\n  progress: number;\n}) {\n  if (input.progress <= 0) return;\n  const amount = Math.sin(input.progress * Math.PI);\n  const bars = 18;\n  ctx.save();\n  ctx.lineCap = 'round';\n  ctx.lineWidth = input.config.ringLineWidth + 1;\n  for (let index = 0; index < bars; index += 1) {\n    const angle = -Math.PI / 2 + (index / bars) * Math.PI * 2 + input.clock * 0.18;\n    const spike = amount * (9 + ((index * 17) % 11));\n    const radius = input.config.ai.radius + 8 + spike * 0.3;\n    const cos = Math.cos(angle);\n    const sin = Math.sin(angle);\n    ctx.strokeStyle = rgba(index % 2 === 0 ? input.config.ai.palette.start : input.config.human.palette.end, amount * 0.62);\n    ctx.beginPath();\n    ctx.moveTo(RING_CENTER + cos * radius, RING_CENTER + sin * radius);\n    ctx.lineTo(RING_CENTER + cos * (radius + spike), RING_CENTER + sin * (radius + spike));\n    ctx.stroke();\n  }\n  ctx.restore();\n}\n\nfunction drawWavePass(ctx: CanvasRenderingContext2D, input: {\n  clock: number;\n  config: VoiceRingConfig;\n  levels: number[];\n  ring: WaveRingConfig;\n  sampleOffset: number;\n  stateAmount: number;\n}) {\n  ctx.save();\n  ctx.globalCompositeOperation = 'source-over';\n  ctx.lineCap = 'round';\n  ctx.lineWidth = input.ring.barWidth;\n  const barPresence = input.stateAmount;\n\n  for (let index = 0; index < input.ring.barCount; index += 1) {\n    const angle = -Math.PI / 2 + (index / input.ring.barCount) * Math.PI * 2;\n    const sampledLevel = input.levels[index] ?? input.config.levelFloor;\n    const level = exciteLevel(levelAboveFloor(sampledLevel, input.config.levelFloor), input.ring.excitement) * input.stateAmount;\n    const height = input.ring.minBarHeight * barPresence + level * input.ring.maxBarHeight;\n    if (height <= 0.2) continue;\n    const innerRadius = input.ring.direction === 'out' ? input.ring.radius : input.ring.radius - height;\n    const outerRadius = input.ring.direction === 'out' ? input.ring.radius + height : input.ring.radius;\n    const colorShift = (Math.sin(index * 0.19 + input.sampleOffset * 0.018 + input.clock * 0.22 + (input.ring.direction === 'out' ? 0.4 : 1.7)) + 1) / 2;\n    const alpha = clamp((barPresence * 0.36 + level * 0.64) * input.ring.opacity, 0, 1);\n    const cos = Math.cos(angle);\n    const sin = Math.sin(angle);\n    const x1 = RING_CENTER + cos * innerRadius;\n    const y1 = RING_CENTER + sin * innerRadius;\n    const x2 = RING_CENTER + cos * outerRadius;\n    const y2 = RING_CENTER + sin * outerRadius;\n    const gradient = ctx.createLinearGradient(x1, y1, x2, y2);\n    if (input.ring.direction === 'out') {\n      const baseColor = input.ring.palette.start;\n      const middleColor = mixRgb(input.ring.palette.start, input.ring.palette.middle, 0.56 + colorShift * 0.28);\n      const tipColor = mixRgb(input.ring.palette.middle, input.ring.palette.end, 0.72 + colorShift * 0.2);\n      gradient.addColorStop(0, rgba(baseColor, alpha * 0.46));\n      gradient.addColorStop(0.5, rgba(middleColor, alpha));\n      gradient.addColorStop(1, rgba(tipColor, alpha * 0.9));\n    } else {\n      const innerColor = mixRgb(input.ring.palette.start, input.ring.palette.middle, colorShift * 0.34);\n      const middleColor = mixRgb(input.ring.palette.middle, input.ring.palette.end, 0.36 + colorShift * 0.22);\n      const baseColor = input.ring.palette.end;\n      gradient.addColorStop(0, rgba(innerColor, alpha * 0.9));\n      gradient.addColorStop(0.5, rgba(middleColor, alpha));\n      gradient.addColorStop(1, rgba(baseColor, alpha * 0.46));\n    }\n\n    ctx.strokeStyle = gradient;\n    ctx.beginPath();\n    ctx.moveTo(x1, y1);\n    ctx.lineTo(x2, y2);\n    ctx.stroke();\n  }\n\n  ctx.restore();\n}\n\nfunction drawWaveRing(ctx: CanvasRenderingContext2D, input: {\n  clock: number;\n  config: VoiceRingConfig;\n  levels: number[];\n  ring: WaveRingConfig;\n  sampleOffset: number;\n  state: VoiceRingState;\n  stateAmount: number;\n}) {\n  drawWavePass(ctx, input);\n}\n\nfunction drawVoiceCanvas(\n  canvas: HTMLCanvasElement,\n  input: {\n    aiLevels: number[];\n    clock: number;\n    config: VoiceRingConfig;\n    humanLevels: number[];\n    interruptProgress: number;\n    state: VoiceRingState;\n    stateAmount: number;\n    waveOffset: number;\n  },\n) {\n  const rect = canvas.getBoundingClientRect();\n  const cssWidth = Math.max(1, rect.width);\n  const cssHeight = Math.max(1, rect.height);\n  const pixelRatio = Math.max(1, window.devicePixelRatio || 1);\n  const backingWidth = Math.round(cssWidth * pixelRatio);\n  const backingHeight = Math.round(cssHeight * pixelRatio);\n  if (canvas.width !== backingWidth || canvas.height !== backingHeight) {\n    canvas.width = backingWidth;\n    canvas.height = backingHeight;\n  }\n\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return;\n\n  ctx.setTransform(backingWidth / VOICE_CANVAS_SIZE, 0, 0, backingHeight / VOICE_CANVAS_SIZE, 0, 0);\n  ctx.clearRect(0, 0, VOICE_CANVAS_SIZE, VOICE_CANVAS_SIZE);\n\n  const idleRingAmount = input.state === 'disconnected'\n    ? 1 - input.stateAmount\n    : Math.max(0, 1 - input.stateAmount * 3.2);\n  drawRingCircle(ctx, {\n    amount: idleRingAmount,\n    config: input.config,\n    palette: idleRingPalette(input.config),\n    radius: input.config.ai.radius,\n  });\n  drawWaveRing(ctx, {\n    clock: input.clock,\n    config: input.config,\n    levels: input.aiLevels,\n    ring: input.config.ai,\n    sampleOffset: input.waveOffset,\n    state: input.state,\n    stateAmount: input.stateAmount,\n  });\n  drawWaveRing(ctx, {\n    clock: input.clock,\n    config: input.config,\n    levels: input.humanLevels,\n    ring: input.config.human,\n    sampleOffset: input.waveOffset,\n    state: input.state,\n    stateAmount: input.stateAmount,\n  });\n  drawStateArc(ctx, {\n    clock: input.clock,\n    config: input.config,\n    palette: input.config.ai.palette,\n    radius: input.config.ai.radius + input.config.ai.minBarHeight + 8,\n    state: input.state,\n    stateAmount: input.stateAmount,\n  });\n  drawInterruptBurst(ctx, {\n    clock: input.clock,\n    config: input.config,\n    progress: input.interruptProgress,\n  });\n}\n\nexport function VoiceRingButton({\n  aiLevels,\n  ariaLabel,\n  canvasClassName,\n  className,\n  config: configInput,\n  disabled = false,\n  humanLevels,\n  onClick,\n  state,\n  style,\n  testId = 'start-conversation-button',\n}: VoiceRingButtonProps): ReactElement {\n  const config = mergeVoiceRingConfig(configInput);\n  const [stateAmount, setStateAmount] = useState(stateTarget(state));\n  const [waveOffset, setWaveOffset] = useState(0);\n  const [clock, setClock] = useState(0);\n  const [resizeKey, setResizeKey] = useState(0);\n  const [interruptProgress, setInterruptProgress] = useState(0);\n  const waveCanvasRef = useRef<HTMLCanvasElement | null>(null);\n  const animationFrameRef = useRef<number | null>(null);\n  const transitionFrameRef = useRef<number | null>(null);\n  const interruptFrameRef = useRef<number | null>(null);\n  const stateAmountRef = useRef(stateTarget(state));\n  const lastFrameAtRef = useRef(0);\n\n  const flatAiLevels = makeFlatLevels(config.ai.barCount, config.levelFloor);\n  const flatHumanLevels = makeFlatLevels(config.human.barCount, config.levelFloor);\n  const liveAiLevels = state !== 'disconnected' && aiLevels ? aiLevels : flatAiLevels;\n  const liveHumanLevels = state !== 'disconnected' && humanLevels ? humanLevels : flatHumanLevels;\n  const visibleAiLevels = mixLevels(flatAiLevels, liveAiLevels, stateAmount, config.levelFloor);\n  const visibleHumanLevels = mixLevels(flatHumanLevels, liveHumanLevels, stateAmount, config.levelFloor);\n  const blendedAiLevels = blendWaveLevels({\n    levelFloor: config.levelFloor,\n    primaryLevels: visibleAiLevels,\n    primaryWeight: WAVE_BLEND_PRIMARY_WEIGHT,\n    ring: config.ai,\n    sampleOffset: waveOffset,\n    secondaryLevels: visibleHumanLevels,\n  });\n  const blendedHumanLevels = blendWaveLevels({\n    levelFloor: config.levelFloor,\n    primaryLevels: visibleHumanLevels,\n    primaryWeight: WAVE_BLEND_PRIMARY_WEIGHT,\n    ring: config.human,\n    sampleOffset: waveOffset,\n    secondaryLevels: visibleAiLevels,\n  });\n  const voiceScale = stateScale(state) + stateAmount * voiceIntensityFromLevels(humanLevels, config.levelFloor) * 0.045;\n  const buttonStyle = {\n    alignItems: 'center',\n    aspectRatio: '1',\n    background: 'transparent',\n    border: 0,\n    color: 'inherit',\n    cursor: disabled ? 'wait' : 'pointer',\n    display: 'inline-flex',\n    justifyContent: 'center',\n    outline: 'none',\n    padding: 0,\n    position: 'relative',\n    transform: 'scale(var(--voice-state-scale))',\n    transition: 'opacity 180ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1)',\n    width: 'min(100%, 28rem)',\n    ...style,\n    '--voice-state-scale': voiceScale,\n  } as CSSProperties;\n  const canvasStyle = {\n    height: '100%',\n    inset: 0,\n    pointerEvents: 'none',\n    position: 'absolute',\n    width: '100%',\n  } as CSSProperties;\n\n  useEffect(() => {\n    const canvas = waveCanvasRef.current;\n    if (!canvas) return;\n    drawVoiceCanvas(canvas, {\n      aiLevels: blendedAiLevels,\n      clock,\n      config,\n      humanLevels: blendedHumanLevels,\n      interruptProgress,\n      state,\n      stateAmount,\n      waveOffset,\n    });\n  }, [blendedAiLevels, blendedHumanLevels, clock, config, interruptProgress, resizeKey, state, stateAmount, waveOffset]);\n\n  useEffect(() => {\n    if (transitionFrameRef.current !== null) {\n      cancelAnimationFrame(transitionFrameRef.current);\n      transitionFrameRef.current = null;\n    }\n\n    const startedAt = performance.now();\n    const fromProgress = stateAmountRef.current;\n    const targetProgress = stateTarget(state);\n\n    const step = (now: number) => {\n      const elapsed = clamp((now - startedAt) / config.transitionMs, 0, 1);\n      const nextProgress = fromProgress + (targetProgress - fromProgress) * easeInOutCubic(elapsed);\n      stateAmountRef.current = nextProgress;\n      setStateAmount(nextProgress);\n      if (elapsed < 1) {\n        transitionFrameRef.current = requestAnimationFrame(step);\n        return;\n      }\n      transitionFrameRef.current = null;\n      if (targetProgress === 0) {\n        setWaveOffset(0);\n      }\n    };\n\n    transitionFrameRef.current = requestAnimationFrame(step);\n    return () => {\n      if (transitionFrameRef.current !== null) {\n        cancelAnimationFrame(transitionFrameRef.current);\n        transitionFrameRef.current = null;\n      }\n    };\n  }, [config.transitionMs, state]);\n\n  useEffect(() => {\n    if (state !== 'interrupted') return undefined;\n    if (interruptFrameRef.current !== null) cancelAnimationFrame(interruptFrameRef.current);\n    const startedAt = performance.now();\n    const step = (now: number) => {\n      const elapsed = clamp((now - startedAt) / 420, 0, 1);\n      setInterruptProgress(elapsed < 1 ? elapsed : 0);\n      if (elapsed < 1) {\n        interruptFrameRef.current = requestAnimationFrame(step);\n        return;\n      }\n      interruptFrameRef.current = null;\n    };\n    interruptFrameRef.current = requestAnimationFrame(step);\n    return () => {\n      if (interruptFrameRef.current !== null) cancelAnimationFrame(interruptFrameRef.current);\n      interruptFrameRef.current = null;\n    };\n  }, [state]);\n\n  useEffect(() => {\n    const canvas = waveCanvasRef.current;\n    if (!canvas || typeof ResizeObserver === 'undefined') return;\n    const resizeObserver = new ResizeObserver(() => setResizeKey((current) => current + 1));\n    resizeObserver.observe(canvas);\n    return () => resizeObserver.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (animationFrameRef.current !== null) {\n      cancelAnimationFrame(animationFrameRef.current);\n      animationFrameRef.current = null;\n    }\n\n    if (state === 'disconnected') return undefined;\n\n    lastFrameAtRef.current = performance.now();\n\n    const step = (now: number) => {\n      const elapsedSeconds = clamp((now - lastFrameAtRef.current) / 1000, 0, 0.05);\n      lastFrameAtRef.current = now;\n\n      setClock((current) => current + elapsedSeconds);\n      setWaveOffset((current) => current + elapsedSeconds * WAVE_ROTATION_SPEED);\n\n      animationFrameRef.current = requestAnimationFrame(step);\n    };\n\n    animationFrameRef.current = requestAnimationFrame(step);\n\n    return () => {\n      if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current);\n      animationFrameRef.current = null;\n    };\n  }, [state]);\n\n  return (\n    <button\n      aria-label={ariaLabel ?? (state === 'disconnected' ? 'Start voice' : 'Stop voice')}\n      className={joinClassNames('voice-ring-button', className)}\n      data-state={state}\n      data-testid={testId}\n      disabled={disabled}\n      onClick={onClick}\n      style={buttonStyle}\n      type=\"button\"\n    >\n      <canvas\n        aria-hidden=\"true\"\n        className={joinClassNames('voice-wave-canvas', canvasClassName)}\n        data-testid=\"voice-wave-canvas\"\n        height={VOICE_CANVAS_SIZE}\n        ref={waveCanvasRef}\n        style={canvasStyle}\n        width={VOICE_CANVAS_SIZE}\n      />\n    </button>\n  );\n}\n","path":"packages/agora-realtime-react/src/components/voice-ring-button.tsx","target":"@ui/voice-ring-button.tsx","type":"registry:ui"},{"content":"'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nconst DEFAULT_LEVEL_FLOOR = 0.12;\nconst DEFAULT_VOICE_METER_FREQUENCY_RANGE: VoiceMeterFrequencyRange = { minHz: 80 };\n\nexport type VoiceMeterMetricRange = {\n  floor?: number;\n  max: number;\n  min: number;\n};\n\nexport type VoiceMeterFrequencyRange = {\n  maxHz?: number;\n  minHz: number;\n};\n\nexport type VoiceMeterOptions<TInput> = {\n  barCount: number;\n  divisor: number;\n  frequencyRange?: VoiceMeterFrequencyRange;\n  metricRange?: VoiceMeterMetricRange;\n  smoothingTimeConstant: number;\n  streamFromInput: (input: TInput) => MediaStream;\n};\n\nexport type VoiceMeterReturn<TInput> = {\n  levels: number[] | null;\n  start: (input: TInput) => void;\n  stop: () => void;\n};\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction getAudioContextConstructor(): typeof AudioContext | undefined {\n  return window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\nfunction normalizeMetricLevel(rawLevel: number, metricRange: VoiceMeterMetricRange): number {\n  const floor = clamp(metricRange.floor ?? DEFAULT_LEVEL_FLOOR, 0, 1);\n  const min = clamp(metricRange.min, 0, 0.999);\n  const max = clamp(Math.max(metricRange.max, min + 0.001), min + 0.001, 4);\n  if (rawLevel <= min) return floor;\n  if (rawLevel >= max) return 1;\n  return floor + ((rawLevel - min) / (max - min)) * (1 - floor);\n}\n\nfunction visibleFrequencyBins(input: {\n  bins: Uint8Array;\n  fftSize: number;\n  frequencyRange: VoiceMeterFrequencyRange;\n  sampleRate: number;\n}) {\n  const hzPerBin = input.sampleRate / input.fftSize;\n  const nyquistHz = input.sampleRate / 2;\n  const minHz = clamp(input.frequencyRange.minHz, 0, nyquistHz);\n  const maxHz = clamp(input.frequencyRange.maxHz ?? nyquistHz, minHz + hzPerBin, nyquistHz);\n  const startBin = clamp(Math.ceil(minHz / hzPerBin), 0, Math.max(0, input.bins.length - 1));\n  const endBin = clamp(Math.ceil(maxHz / hzPerBin), startBin + 1, input.bins.length);\n  return { endBin, startBin };\n}\n\nfunction sampleFrequencyLevels(\n  bins: Uint8Array,\n  count: number,\n  divisor: number,\n  metricRange: VoiceMeterMetricRange,\n  frequencyRange: VoiceMeterFrequencyRange,\n  sampleRate: number,\n  fftSize: number,\n) {\n  const { endBin, startBin } = visibleFrequencyBins({ bins, fftSize, frequencyRange, sampleRate });\n  const visibleBinCount = endBin - startBin;\n  return Array.from({ length: count }, (_, index) => {\n    const start = startBin + Math.floor((index / count) * visibleBinCount);\n    const end = Math.max(start + 1, startBin + Math.floor(((index + 1) / count) * visibleBinCount));\n    let sum = 0;\n    for (let bin = start; bin < end; bin += 1) sum += bins[bin] ?? 0;\n    return normalizeMetricLevel((sum / (end - start)) / divisor, metricRange);\n  });\n}\n\nexport function microphoneCompatibilityWarning(): string | null {\n  if (typeof window === 'undefined') return null;\n  if (!window.isSecureContext) return 'HTTPS is required for microphone capture on this browser.';\n  if (!navigator.mediaDevices?.getUserMedia) return 'This browser does not expose microphone capture.';\n  return null;\n}\n\nexport function useVoiceMeter<TInput>({\n  barCount,\n  divisor,\n  frequencyRange = DEFAULT_VOICE_METER_FREQUENCY_RANGE,\n  metricRange = { max: 1, min: 0 },\n  smoothingTimeConstant,\n  streamFromInput,\n}: VoiceMeterOptions<TInput>): VoiceMeterReturn<TInput> {\n  const [levels, setLevels] = useState<number[] | null>(null);\n  const animationRef = useRef<number | null>(null);\n  const contextRef = useRef<AudioContext | null>(null);\n  const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);\n\n  const stop = useCallback(() => {\n    if (animationRef.current !== null) {\n      cancelAnimationFrame(animationRef.current);\n      animationRef.current = null;\n    }\n    sourceRef.current?.disconnect();\n    sourceRef.current = null;\n    void contextRef.current?.close();\n    contextRef.current = null;\n    setLevels(null);\n  }, []);\n\n  const start = useCallback((input: TInput) => {\n    stop();\n    const AudioContextConstructor = getAudioContextConstructor();\n    if (!AudioContextConstructor) return;\n\n    const context = new AudioContextConstructor({ sampleRate: 24000 });\n    if (context.state === 'suspended') void context.resume();\n    const analyser = context.createAnalyser();\n    analyser.fftSize = 512;\n    analyser.smoothingTimeConstant = smoothingTimeConstant;\n    const source = context.createMediaStreamSource(streamFromInput(input));\n    source.connect(analyser);\n    contextRef.current = context;\n    sourceRef.current = source;\n\n    const bins = new Uint8Array(analyser.frequencyBinCount);\n    const resolvedMetricRange = {\n      floor: metricRange.floor ?? DEFAULT_LEVEL_FLOOR,\n      max: metricRange.max,\n      min: metricRange.min,\n    };\n    const resolvedFrequencyRange: VoiceMeterFrequencyRange = {\n      minHz: frequencyRange.minHz,\n      ...(frequencyRange.maxHz === undefined ? {} : { maxHz: frequencyRange.maxHz }),\n    };\n    const draw = () => {\n      analyser.getByteFrequencyData(bins);\n      setLevels(sampleFrequencyLevels(\n        bins,\n        barCount,\n        divisor,\n        resolvedMetricRange,\n        resolvedFrequencyRange,\n        context.sampleRate,\n        analyser.fftSize,\n      ));\n      animationRef.current = requestAnimationFrame(draw);\n    };\n    draw();\n  }, [\n    barCount,\n    divisor,\n    frequencyRange.maxHz,\n    frequencyRange.minHz,\n    metricRange.floor,\n    metricRange.max,\n    metricRange.min,\n    smoothingTimeConstant,\n    stop,\n    streamFromInput,\n  ]);\n\n  useEffect(() => stop, [stop]);\n\n  return { levels, start, stop };\n}\n","path":"packages/agora-realtime-react/src/use-voice-meter.ts","target":"@hooks/use-voice-meter.ts","type":"registry:hook"}],"name":"voice-ring-button","title":"Agora Voice Ring Button","type":"registry:ui"}