{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "soundwave",
  "type": "registry:component",
  "title": "Soundwave",
  "description": "A voice drawn while an app listens: capsules, bars, a line, a field of dots, spokes round a person, or rings of dots.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/soundwave.tsx",
      "type": "registry:component",
      "target": "@components/panelui/soundwave.tsx",
      "content": "'use client';\n\n/**\n * Soundwave — what a voice looks like while an app listens to it.\n *\n * ```tsx\n * <Soundwave variant=\"dots\" state=\"listening\" level={level} />\n *\n * <Soundwave variant=\"radial\" state=\"speaking\">\n *   <Soundwave.Center>\n *     <Avatar name=\"Khalid Abdi\" size=\"xl\" />\n *   </Soundwave.Center>\n * </Soundwave>\n * ```\n *\n * Seven looks. Four are the app's: `pills` over a microphone button, `bars`\n * for a metering strip, `line` for a travelling wave, `ambient` for a glow\n * behind a whole screen. Three are drawn in dots and spokes: `dots`, a field\n * of dots lit into a wave; `radial`, spokes around a person; and `ring`,\n * rings of dots lit outward by angle.\n *\n * ## It draws a level; it does not record one\n *\n * Nothing here touches the microphone. The app owns the recorder and hands\n * over a number from 0 to 1, so the same wave works against a real meter, a\n * synthesised one or a remote peer's. With no level it runs its own motion\n * for the current `state`, so a voice screen can be built before any audio\n * exists.\n *\n * ## Levels arrive faster than React should render\n *\n * A meter reports dozens of times a second. `level` takes a ref as well as a\n * number: write each reading into `ref.current` and the wave picks it up on\n * its next frame with nothing above it rendering. Each frame writes the `d`\n * of one or two SVG paths, never React state, and the dots are zero-length\n * round-capped segments, so a field of four hundred dots is a single path.\n *\n * Either way the level is smoothed with a fast attack and a slow release,\n * which is what makes it read as a meter: it snaps up on a syllable and falls\n * back gently instead of chattering on every sample.\n */\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type SoundwaveVariant = 'pills' | 'bars' | 'line' | 'ambient' | 'dots' | 'radial' | 'ring';\n\nexport type SoundwaveState = 'idle' | 'listening' | 'thinking' | 'speaking';\n\n/** A role from the theme. The wave never carries a colour of its own. */\nexport type SoundwaveColor =\n  | 'foreground'\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\n\n/** What each state is doing, for anyone who cannot see it. */\nconst STATE_LABEL: Record<SoundwaveState, string> = {\n  idle: 'Idle',\n  listening: 'Listening',\n  thinking: 'Thinking',\n  speaking: 'Speaking',\n};\n\n/**\n * How fast the smoothed level rises and falls, per second. Lopsided on\n * purpose: snapping up and easing down is how an ear expects a meter to move.\n */\nconst ATTACK = 16;\nconst RELEASE = 5;\n\n/** Never quite silent: a wave flat at zero reads as broken rather than quiet. */\nconst FLOOR = 0.04;\n\n/** How often `scrolling` takes a sample, in seconds. */\nconst SAMPLE_INTERVAL = 1 / 24;\n\n/** Opacity of the part of a recording that has not played yet. */\nconst UNPLAYED = 0.3;\n\n/* -------------------------------------------------------------------------- */\n/* Maths                                                                      */\n/* -------------------------------------------------------------------------- */\n\nconst clamp01 = (value: number) => (value < 0 ? 0 : value > 1 ? 1 : value);\n\n/** Deterministic hash in `[0, 1)`, the same on the server and in the browser. */\nfunction hash(a: number, b: number): number {\n  const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;\n  return h - Math.floor(h);\n}\n\n/** One decimal place. Paths are rebuilt every frame, and every digit costs. */\nconst q = (value: number) => Math.round(value * 10) / 10;\n\n/**\n * The motion a state runs with no level supplied. Layered at unrelated tempi\n * so it never repeats on a beat; a single sine reads as a progress pulse.\n */\nfunction idleEnergy(state: SoundwaveState, t: number): number {\n  if (state === 'listening') {\n    return clamp01(0.34 + 0.22 * Math.sin(t * 2.3) + 0.16 * Math.sin(t * 3.9 + 1.3) + 0.1 * Math.sin(t * 7.1 + 0.6));\n  }\n  if (state === 'speaking') {\n    return clamp01(0.46 + 0.27 * Math.sin(t * 3.1) + 0.18 * Math.sin(t * 5.7 + 0.8) + 0.09 * Math.sin(t * 9.3));\n  }\n  if (state === 'thinking') {\n    return clamp01(0.18 + 0.1 * Math.sin(t * 1.6) + 0.05 * Math.sin(t * 2.9 + 2.1));\n  }\n  return clamp01(0.07 + 0.05 * Math.sin(t * 1.1));\n}\n\n/** A supplied series read at `i` of `count`, so the two lengths need not match. */\nconst resample = (values: readonly number[], i: number, count: number) =>\n  clamp01(values[Math.floor((i / count) * values.length)] ?? 0);\n\n/* -------------------------------------------------------------------------- */\n/* Geometry                                                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface Frame {\n  /** Seconds since mount, scaled by `speed`. */\n  clock: number;\n  /** Smoothed level, 0–1. */\n  energy: number;\n  /** Newest last. Only kept in `scrolling` mode. */\n  samples: number[];\n  /** When `scrolling` takes its next sample, on `clock`. */\n  nextSample: number;\n}\n\ninterface Geometry {\n  variant: SoundwaveVariant;\n  width: number;\n  height: number;\n  count: number;\n  barWidth: number;\n  barGap: number;\n  centered: boolean;\n  scrolling: boolean;\n  levels: readonly number[] | undefined;\n  progress: number | undefined;\n}\n\n/** Path data for each layer a look draws, and the glow's strength. */\ninterface Paint {\n  ink?: string;\n  rest?: string;\n  edge?: string;\n  tip?: string;\n  track?: string;\n  glow?: number;\n}\n\n/** One strip bar's height, 0–1, from whichever source drives the wave. */\nfunction stripValue(i: number, count: number, g: Geometry, f: Frame): number {\n  if (g.scrolling) {\n    // The buffer fills from empty, so the newest sample sits at the trailing\n    // edge from the first frame rather than sliding in from nowhere.\n    const offset = i - (count - f.samples.length);\n    return offset >= 0 ? (f.samples[offset] ?? 0) : 0;\n  }\n  if (g.levels?.length) return resample(g.levels, i, count);\n  const rate = 2.1 + hash(i, 1.3) * 2.9;\n  const phase = hash(i, 4.7) * Math.PI * 2;\n  const wobble = 0.5 + 0.5 * Math.sin(f.clock * rate + phase);\n  // The middle leads. A flat profile reads as a progress bar, not a voice.\n  const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1;\n  // A slow swell travelling along the strip, so neighbouring bars rise\n  // together into a shape rather than each one flickering on its own.\n  const swell = 0.7 + 0.3 * Math.sin((i / count) * 7 - f.clock * 1.3);\n  return clamp01(f.energy * 1.8 * (0.3 + 0.7 * wobble) * (0.35 + 0.65 * centre) * swell);\n}\n\n/** One spoke's or angle's value for the round looks, which have no middle to lead. */\nfunction roundValue(i: number, count: number, g: Geometry, f: Frame): number {\n  if (g.levels?.length) return resample(g.levels, i, count);\n  const angle = (i / count) * Math.PI * 2;\n  const rate = 1.8 + hash(i, 2.1) * 3.2;\n  const phase = hash(i, 6.3) * Math.PI * 2;\n  const wobble = 0.5 + 0.5 * Math.sin(f.clock * rate + phase);\n  // Two slow lobes travelling round, so the outline swells unevenly rather\n  // than every spoke breathing as one.\n  const lobe = 0.72 + 0.28 * Math.sin(angle * 3 + f.clock * 0.9) * Math.cos(angle * 2 - f.clock * 0.6);\n  return clamp01(f.energy * 1.6 * (0.3 + 0.7 * wobble) * lobe);\n}\n\nconst dot = (x: number, y: number) => `M${q(x)} ${q(y)}h0`;\nconst segment = (x1: number, y1: number, x2: number, y2: number) => `M${q(x1)} ${q(y1)}L${q(x2)} ${q(y2)}`;\n\nfunction paintPills(g: Geometry, f: Frame): Paint {\n  const { count, barWidth: w, barGap: gap, height: h } = g;\n  let ink = '';\n  for (let i = 0; i < count; i++) {\n    // Each capsule keeps its own tempo and phase, so the group answers one\n    // level without moving as a block.\n    const rate = 2.2 + hash(i, 1.7) * 2.6;\n    const phase = hash(i, 5.1) * Math.PI * 2;\n    const wobble = 0.55 + 0.45 * Math.sin(f.clock * rate + phase);\n    const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1;\n    const value = g.levels?.length\n      ? resample(g.levels, i, count)\n      : clamp01(Math.max(FLOOR, f.energy * 1.5 * wobble * (0.6 + 0.4 * centre)));\n    const length = (h - w) * value;\n    const x = w / 2 + i * (w + gap);\n    ink += segment(x, (h - length) / 2, x, (h + length) / 2);\n  }\n  return { ink };\n}\n\nfunction paintBars(g: Geometry, f: Frame): Paint {\n  const { count, width, height: h, barWidth: w } = g;\n  if (!width) return {};\n  const pitch = width / count;\n  const played = g.progress === undefined ? count : clamp01(g.progress) * count;\n  let ink = '';\n  let rest = '';\n  for (let i = 0; i < count; i++) {\n    const value = Math.max(FLOOR, stripValue(i, count, g, f));\n    const length = Math.max(0, value * h - w);\n    const x = pitch * (i + 0.5);\n    const y1 = g.centered ? (h - length) / 2 : h - w / 2 - length;\n    const path = segment(x, y1, x, y1 + length);\n    if (i < played) ink += path;\n    else rest += path;\n  }\n  return { ink, rest };\n}\n\nconst LINE_POINTS = 56;\n\nfunction paintLine(g: Geometry, f: Frame): Paint {\n  const { width, height: h, barWidth: w } = g;\n  if (!width) return {};\n  const amplitude = h / 2 - w;\n  let ink = '';\n  for (let j = 0; j < LINE_POINTS; j++) {\n    const u = j / (LINE_POINTS - 1);\n    // Tapered to nothing at both ends, so it leaves the edges flat instead of\n    // stopping at a hard cut.\n    const taper = Math.pow(Math.sin(Math.PI * u), 1.6);\n    const wave = 0.62 * Math.sin(u * 9 - f.clock * 3.4) + 0.38 * Math.sin(u * 15.5 + f.clock * 2.3 + 1.1);\n    const y = h / 2 + amplitude * taper * Math.max(FLOOR, f.energy * 1.3) * wave;\n    ink += `${j ? 'L' : 'M'}${q(u * width)} ${q(y)}`;\n  }\n  return { ink };\n}\n\nfunction paintDots(g: Geometry, f: Frame): Paint {\n  const { width, height, barWidth: size, barGap: gap } = g;\n  if (!width) return {};\n  const pitch = size + gap;\n  const columns = Math.max(1, Math.floor((width + gap) / pitch));\n  let rows = Math.max(3, Math.floor((height + gap) / pitch));\n  if (g.centered && rows % 2 === 0) rows -= 1;\n  const left = (width - (columns * pitch - gap)) / 2 + size / 2;\n  const top = (height - (rows * pitch - gap)) / 2 + size / 2;\n  const half = (rows - 1) / 2;\n  const played = g.progress === undefined ? columns : clamp01(g.progress) * columns;\n  let ink = '';\n  let rest = '';\n  let edge = '';\n  let track = '';\n  for (let c = 0; c < columns; c++) {\n    const raw = Math.max(FLOOR, stripValue(c, columns, g, f));\n    /*\n     * A live wave is eased up, because a field of dots has few steps and on a\n     * straight scale an ordinary voice lit only the middle row or two. Easing\n     * flattens the shape as well, so the middle is made to lead again after\n     * it. Supplied `levels` are drawn as given.\n     */\n    const centre = Math.sin((Math.PI * (c + 0.5)) / columns);\n    const value = g.levels?.length || g.scrolling ? raw : Math.pow(raw, 0.6) * (0.3 + 0.7 * Math.pow(centre, 0.7));\n    // How many dots out from the middle (or up from the bottom) are lit. The\n    // part of a dot left over lights the next one faintly, so the outline\n    // moves smoothly rather than a whole dot at a time.\n    const reach = value * (g.centered ? half : rows - 1);\n    const full = Math.floor(reach);\n    const partial = reach - full > 0.35;\n    const x = left + c * pitch;\n    for (let r = 0; r < rows; r++) {\n      const out = g.centered ? Math.abs(r - half) : rows - 1 - r;\n      const path = dot(x, top + r * pitch);\n      if (out <= full) {\n        if (c < played) ink += path;\n        else rest += path;\n      } else if (out === full + 1 && partial) edge += path;\n      else track += path;\n    }\n  }\n  return { ink, rest, edge, track };\n}\n\n/** Where the ring sits for `radial` and `ring`, as a fraction of the diameter. */\nconst HOLE = 0.3;\n\nfunction paintRadial(g: Geometry, f: Frame): Paint {\n  const { count, height: size, barWidth: w } = g;\n  const c = size / 2;\n  const start = size * (HOLE + 0.05);\n  const reach = c - start - w / 2 - 1;\n  let ink = '';\n  let tip = '';\n  for (let i = 0; i < count; i++) {\n    const angle = (i / count) * Math.PI * 2 - Math.PI / 2;\n    const value = roundValue(i, count, g, f);\n    const length = reach * (0.1 + 0.9 * value);\n    // The inner part carries the ink and the outer part is fainter, so a\n    // spoke reads as a level with a softer reach beyond it.\n    const split = start + length * 0.58;\n    const cos = Math.cos(angle);\n    const sin = Math.sin(angle);\n    ink += segment(c + cos * start, c + sin * start, c + cos * split, c + sin * split);\n    tip += segment(c + cos * split, c + sin * split, c + cos * (start + length), c + sin * (start + length));\n  }\n  return { ink, tip, glow: g.levels?.length ? 0.4 : f.energy };\n}\n\nfunction paintRing(g: Geometry, f: Frame): Paint {\n  const { count, height: size, barWidth: d, barGap: gap } = g;\n  const c = size / 2;\n  const pitch = d + gap;\n  const inner = size * HOLE;\n  const outer = c - d / 2 - 0.5;\n  const rings = Math.max(2, Math.floor((outer - inner) / pitch) + 1);\n  let ink = '';\n  let edge = '';\n  let track = '';\n  for (let i = 0; i < count; i++) {\n    const angle = (i / count) * Math.PI * 2 - Math.PI / 2;\n    const reach = Math.max(FLOOR, roundValue(i, count, g, f)) * rings;\n    const full = Math.floor(reach);\n    const partial = reach - full > 0.35;\n    const cos = Math.cos(angle);\n    const sin = Math.sin(angle);\n    for (let k = 0; k < rings; k++) {\n      const r = inner + k * pitch;\n      const path = dot(c + cos * r, c + sin * r);\n      if (k < full) ink += path;\n      else if (k === full && partial) edge += path;\n      else track += path;\n    }\n  }\n  return { ink, edge, track };\n}\n\nfunction paint(g: Geometry, f: Frame): Paint {\n  switch (g.variant) {\n    case 'pills':\n      return paintPills(g, f);\n    case 'bars':\n      return paintBars(g, f);\n    case 'line':\n      return paintLine(g, f);\n    case 'dots':\n      return paintDots(g, f);\n    case 'radial':\n      return paintRadial(g, f);\n    case 'ring':\n      return paintRing(g, f);\n    default:\n      return { glow: f.energy };\n  }\n}\n\n/** How many bars the strip keeps in its history, for `scrolling`. */\nfunction historyLength(g: Geometry): number {\n  if (!g.scrolling) return 0;\n  if (g.variant === 'bars') return g.count;\n  if (g.variant === 'dots' && g.width) return Math.max(1, Math.floor((g.width + g.barGap) / (g.barWidth + g.barGap)));\n  return 0;\n}\n\n/**\n * A frame that still looks like a wave, for the first render, `paused` and\n * reduced motion. A stopped wave is not an empty one.\n */\nfunction stillFrame(level: number | undefined, history: number): Frame {\n  const energy = level === undefined ? 0.42 : clamp01(level);\n  return {\n    clock: 0,\n    energy,\n    nextSample: 0,\n    samples: Array.from({ length: history }, (_, i) => clamp01(energy * (0.45 + 0.55 * Math.abs(Math.sin(i * 0.7))))),\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Hooks                                                                      */\n/* -------------------------------------------------------------------------- */\n\nconst REDUCE = '(prefers-reduced-motion: reduce)';\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = window.matchMedia(REDUCE);\n  query.addEventListener('change', onChange);\n  return () => query.removeEventListener('change', onChange);\n}\n\nfunction useReducedMotion() {\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCE).matches,\n    () => false\n  );\n}\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nconst isRef = (value: unknown): value is RefObject<number | null> =>\n  typeof value === 'object' && value !== null && 'current' in value;\n\n/* -------------------------------------------------------------------------- */\n/* Component                                                                  */\n/* -------------------------------------------------------------------------- */\n\n/** Per-look geometry, so a bare `<Soundwave variant=\"…\" />` looks right. */\nconst DEFAULTS: Record<SoundwaveVariant, { bars: number; barWidth: number; barGap: number; height: number }> = {\n  pills: { bars: 4, barWidth: 28, barGap: 10, height: 96 },\n  bars: { bars: 40, barWidth: 3, barGap: 3, height: 56 },\n  line: { bars: 0, barWidth: 3, barGap: 0, height: 72 },\n  ambient: { bars: 0, barWidth: 0, barGap: 0, height: 0 },\n  dots: { bars: 0, barWidth: 4, barGap: 3, height: 60 },\n  radial: { bars: 60, barWidth: 3, barGap: 0, height: 176 },\n  ring: { bars: 72, barWidth: 3.5, barGap: 3, height: 208 },\n};\n\nconst COLOR_VAR: Record<SoundwaveColor, string> = {\n  foreground: 'var(--foreground)',\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\nconst soundwaveVariants = tv({\n  slots: {\n    root: 'relative text-(--sw-color,currentColor)',\n    svg: 'absolute inset-0 overflow-visible',\n    // Behind the spokes, in the wave's own colour, and only as strong as the voice.\n    glow: 'pointer-events-none absolute inset-[4%] rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--sw-color,currentColor)_30%,transparent)_30%,transparent_70%)]',\n    center:\n      'absolute top-1/2 left-1/2 flex size-(--sw-hole) -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full',\n    // Ambient: a bloom off the bottom edge and a rim round the rest, both on the level.\n    // Drawn with gradients rather than blur filters, which are costly to\n    // repaint every frame and band badly at this size.\n    bloom: [\n      'absolute inset-x-[-20%] bottom-0 h-[85%] origin-bottom bg-(--sw-fill,var(--sw-color))',\n      '[mask-image:radial-gradient(ellipse_50%_100%_at_50%_100%,black,transparent)]',\n      'opacity-[calc(0.18+var(--sw-e,0.4)*0.5)] [scale:1_calc(0.55+var(--sw-e,0.4)*0.6)]',\n    ],\n    rim: 'absolute inset-0 rounded-[inherit] opacity-[calc(0.25+var(--sw-e,0.4)*0.75)] shadow-[inset_0_0_28px_color-mix(in_oklab,var(--sw-color)_45%,transparent)]',\n  },\n  variants: {\n    variant: {\n      pills: { root: 'inline-block shrink-0' },\n      bars: { root: 'block w-full' },\n      line: { root: 'block w-full' },\n      dots: { root: 'block w-full' },\n      radial: { root: 'inline-block shrink-0' },\n      ring: { root: 'inline-block shrink-0' },\n      ambient: { root: 'pointer-events-none absolute inset-0 overflow-hidden' },\n    },\n    fadeEdges: {\n      true: {\n        svg: '[mask-image:linear-gradient(to_right,transparent,black_14%,black_86%,transparent)]',\n      },\n    },\n  },\n});\n\nexport interface SoundwaveProps extends Omit<ComponentProps<'div'>, 'color' | 'children'> {\n  /**\n   * Which look to draw: `pills` for a few capsules over a microphone button,\n   * `bars` for a metering strip, `line` for a travelling wave, `ambient` for a\n   * glow that fills its parent, `dots` for a field of dots lit into a wave,\n   * `radial` for spokes around a centre, and `ring` for rings of dots lit\n   * outward by angle.\n   */\n  variant?: SoundwaveVariant;\n  /**\n   * What the app is doing. With no `level` it picks the motion the wave runs\n   * on its own, and it always sets what a screen reader announces.\n   */\n  state?: SoundwaveState;\n  /**\n   * How loud the input is now, from 0 to 1. Pass a ref and write each\n   * reading into `ref.current` to keep updates out of React entirely. Leave\n   * it out and the wave runs its own motion for `state`.\n   */\n  level?: number | RefObject<number | null>;\n  /**\n   * Sound to draw, in place of `level`: a `MediaStream` from a microphone or\n   * a call, or an `<audio>` or `<video>` element. A media file has to be on\n   * the same site or sent with CORS headers, or the browser hides its sound\n   * from the page and the wave stays still.\n   */\n  source?: SoundwaveSource | null;\n  /**\n   * Fixed values, 0 to 1 each: the stored shape of a recording, a frequency\n   * analysis, or one value per angle for `ring` (24 for the hours of a day).\n   * Resampled to the bar count, and drawn still rather than animated.\n   */\n  levels?: readonly number[];\n  /**\n   * How far a recording has played, from 0 to 1. Bars and dots behind it keep\n   * full ink and the rest are dimmed. `bars` and `dots`, with `levels`.\n   */\n  progress?: number;\n  /** How many capsules, bars or spokes, or how many angles for `ring`. `dots` fits as many columns as the width allows. */\n  bars?: number;\n  /** Capsule or bar width, the stroke of `line` and `radial`, or the size of a dot. */\n  barWidth?: number;\n  /** Space between capsules, or between dots. `bars` spaces itself across the width. */\n  barGap?: number;\n  /** Drawing height. For `radial` and `ring` it is the diameter. `ambient` fills its parent instead. */\n  height?: number;\n  /**\n   * `static` gives every bar a share of the current level; `scrolling` keeps a\n   * history that slides across, newest at the trailing edge. `bars` and `dots`.\n   */\n  mode?: 'static' | 'scrolling';\n  /** Grow bars and dots from the middle out rather than up from the bottom. */\n  centered?: boolean;\n  /** Fade a strip out at both ends, so it does not stop at a hard edge. */\n  fadeEdges?: boolean;\n  /** Multiplier on the incoming level, applied before it is capped at 1. */\n  sensitivity?: number;\n  /** Multiplier on the wave's own tempo, including how fast a history scrolls. */\n  speed?: number;\n  /**\n   * The ink, as a role from the theme. Left unset the wave is drawn in the\n   * text colour of whatever it sits in, which is right on most surfaces;\n   * `ambient` defaults to `info`, since a glow in the text colour reads as a\n   * shadow.\n   */\n  color?: SoundwaveColor;\n  /**\n   * Colours across the wave instead of one ink, as theme roles: left to right\n   * for the strips, up from the bottom for `ambient`.\n   */\n  gradient?: readonly SoundwaveColor[];\n  /**\n   * The colour of the unlit dots in `dots` and `ring`, and of the part of a\n   * recording not yet played in `bars`. Defaults to the ink, faint.\n   */\n  trackColor?: SoundwaveColor;\n  /** Hold the current frame. */\n  paused?: boolean;\n  /** What a screen reader calls it. Defaults to the state: \"Listening\". */\n  label?: string;\n  /** For `radial` and `ring`: a `Soundwave.Center` holding what sits in the middle. */\n  children?: ReactNode;\n}\n\n/** A voice as a wave. Draws a `level` it is handed, or runs its own motion for `state`. */\nfunction SoundwaveRoot({\n  variant = 'pills',\n  state = 'listening',\n  level: levelProp,\n  source,\n  levels,\n  progress,\n  bars,\n  barWidth,\n  barGap,\n  height,\n  mode = 'static',\n  centered = true,\n  fadeEdges = false,\n  sensitivity = 1,\n  speed = 1,\n  color,\n  gradient,\n  trackColor,\n  paused = false,\n  label,\n  className,\n  style,\n  children,\n  render,\n  ...props\n}: SoundwaveProps & RenderProp<'div'>) {\n  const sourceLevel = useAudioLevel(source);\n  const level = source ? sourceLevel : levelProp;\n  const defaults = DEFAULTS[variant];\n  const count = bars ?? defaults.bars;\n  const w = barWidth ?? defaults.barWidth;\n  const gap = barGap ?? defaults.barGap;\n  const h = height ?? defaults.height;\n  const strip = variant === 'bars' || variant === 'line' || variant === 'dots';\n  const round = variant === 'radial' || variant === 'ring';\n  const scrolling = mode === 'scrolling' && (variant === 'bars' || variant === 'dots');\n\n  const root = useRef<HTMLDivElement>(null);\n  const layers = useRef<Partial<Record<keyof Paint, SVGPathElement | null>>>({});\n  const glow = useRef<HTMLDivElement>(null);\n  // `useId` can hold characters a `url(#…)` reference does not survive.\n  const gradientId = `sw-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;\n  const reducedMotion = useReducedMotion();\n\n  // Strips take the width of their container; the rest know their own.\n  const [measured, setMeasured] = useState(0);\n  const width = variant === 'pills' ? count * w + (count - 1) * gap : round ? h : strip ? measured : 0;\n\n  useIsomorphicLayoutEffect(() => {\n    const node = root.current;\n    if (!strip || !node) return;\n    setMeasured(Math.round(node.getBoundingClientRect().width));\n    if (typeof ResizeObserver === 'undefined') return;\n    const observer = new ResizeObserver(([entry]) => setMeasured(Math.round(entry!.contentRect.width)));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [strip]);\n\n  const geometry: Geometry = {\n    variant,\n    width,\n    height: h,\n    count,\n    barWidth: w,\n    barGap: gap,\n    centered,\n    scrolling,\n    levels: scrolling ? undefined : levels,\n    progress,\n  };\n\n  const readLevel = () => {\n    if (level === undefined) return undefined;\n    const value = isRef(level) ? level.current : level;\n    return value === null || value === undefined ? undefined : value * sensitivity;\n  };\n\n  // Fixed `levels` need no clock; neither does a held frame.\n  const fixed = !scrolling && !!levels?.length && variant !== 'ambient' && variant !== 'line';\n  const animate = !fixed && !paused && !reducedMotion;\n\n  // What the first render and any held frame draw.\n  const still = stillFrame(readLevel(), historyLength(geometry));\n  const painted = paint(geometry, still);\n\n  /*\n   * Everything the frame loop reads, refreshed on each render, so a prop\n   * change reaches the next frame without restarting the loop.\n   */\n  const live = useRef({ geometry, state, speed, readLevel });\n  live.current = { geometry, state, speed, readLevel };\n  const frame = useRef<Frame>(still);\n\n  useEffect(() => {\n    if (!animate) return;\n    const node = root.current;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n\n    const draw = (f: Frame) => {\n      const next = paint(live.current.geometry, f);\n      for (const key of ['ink', 'rest', 'edge', 'tip', 'track'] as const) {\n        const path = layers.current[key];\n        if (path && next[key] !== undefined) path.setAttribute('d', next[key]!);\n      }\n      if (next.glow !== undefined) {\n        if (glow.current) glow.current.style.opacity = String(0.25 + next.glow * 0.75);\n        node?.style.setProperty('--sw-e', String(q(next.glow * 100) / 100));\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      // A long gap (a hidden tab, a slow frame) is clamped rather than\n      // honoured: a 300ms hitch played back at full speed is a lurch.\n      const delta = Math.min(last ? now - last : 16, 48) / 1000;\n      last = now;\n      const { state: current, speed: rate, readLevel: read, geometry: g } = live.current;\n      const f = frame.current;\n      f.clock += delta * rate;\n      const driven = read();\n      const target = driven === undefined ? idleEnergy(current, f.clock) : clamp01(driven);\n      const smoothing = target > f.energy ? ATTACK : RELEASE;\n      f.energy += (target - f.energy) * Math.min(1, delta * smoothing);\n      const history = historyLength(g);\n      if (history > 0) {\n        while (f.samples.length > history) f.samples.shift();\n        if (f.clock >= f.nextSample) {\n          f.nextSample = f.clock + SAMPLE_INTERVAL;\n          // A little texture per sample, so a held note is a band of\n          // varying bars rather than a solid block.\n          const jitter = 0.82 + 0.36 * hash(f.clock, 3.3);\n          if (f.samples.length >= history) f.samples.shift();\n          f.samples.push(clamp01(f.energy * jitter));\n        }\n      }\n      draw(f);\n      schedule();\n    };\n\n    const schedule = () => {\n      if (raf || !visible || document.hidden) return;\n      raf = requestAnimationFrame(tick);\n    };\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    // Off screen, or in a background tab, nothing is drawn.\n    const observer =\n      node && typeof IntersectionObserver !== 'undefined'\n        ? new IntersectionObserver(([entry]) => {\n            visible = !!entry?.isIntersecting;\n            if (visible) schedule();\n            else stop();\n          })\n        : null;\n    if (node) observer?.observe(node);\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n    document.addEventListener('visibilitychange', onVisibility);\n    schedule();\n\n    return () => {\n      stop();\n      observer?.disconnect();\n      document.removeEventListener('visibilitychange', onVisibility);\n    };\n  }, [animate]);\n\n  const slots = soundwaveVariants({ variant, fadeEdges: fadeEdges && strip });\n  const ink = color ? COLOR_VAR[color] : variant === 'ambient' ? COLOR_VAR.info : undefined;\n  // Unlit dots are the track turned well down, so a field stays a quiet\n  // ground under the wave whichever colour it is given.\n  const track =\n    variant === 'dots' || variant === 'ring'\n      ? `color-mix(in oklab, ${trackColor ? COLOR_VAR[trackColor] : 'var(--sw-color, currentColor)'} ${trackColor ? 30 : 14}%, transparent)`\n      : trackColor\n        ? COLOR_VAR[trackColor]\n        : undefined;\n  const stroke = gradient?.length ? `url(#${gradientId})` : 'currentColor';\n  const ambientFill = gradient?.length\n    ? `linear-gradient(to top, ${gradient.map((role) => COLOR_VAR[role]).join(', ')})`\n    : undefined;\n\n  const rootStyle: CSSProperties = {\n    ...(ink ? { ['--sw-color' as string]: ink } : null),\n    ...(ambientFill ? { ['--sw-fill' as string]: ambientFill } : null),\n    ...(variant === 'ambient' ? null : { height: h }),\n    ...(variant === 'pills' || round ? { width } : null),\n    ...(round ? { ['--sw-hole' as string]: `${q(h * HOLE * 2 - (variant === 'ring' ? w + gap : h * 0.04))}px` } : null),\n    ...(painted.glow !== undefined ? { ['--sw-e' as string]: q(painted.glow * 10) / 10 } : null),\n    ...style,\n  };\n\n  const layer = (key: keyof Paint, extra: { opacity?: number; stroke?: string; strokeWidth?: number } = {}) => (\n    <path\n      ref={(node) => {\n        layers.current[key] = node;\n      }}\n      d={(painted[key] as string | undefined) ?? ''}\n      fill=\"none\"\n      stroke={extra.stroke ?? stroke}\n      strokeOpacity={extra.opacity}\n      strokeWidth={extra.strokeWidth ?? w}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  );\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        ref: root,\n        role: 'img',\n        'aria-label': label ?? STATE_LABEL[state],\n        'data-slot': 'soundwave',\n        'data-variant': variant,\n        'data-state': state,\n        className: slots.root({ className }),\n        style: rootStyle,\n        children: (\n          <>\n            {variant === 'ambient' ? (\n              <>\n                <div aria-hidden=\"true\" className={slots.bloom()} />\n                <div aria-hidden=\"true\" className={slots.rim()} />\n              </>\n            ) : (\n              <>\n                {variant === 'radial' ? (\n                  <div\n                    ref={glow}\n                    aria-hidden=\"true\"\n                    className={slots.glow()}\n                    style={{ opacity: 0.25 + (painted.glow ?? 0) * 0.75 }}\n                  />\n                ) : null}\n                {width > 0 ? (\n                  <svg\n                    aria-hidden=\"true\"\n                    width={width}\n                    height={h}\n                    viewBox={`0 0 ${width} ${h}`}\n                    className={slots.svg()}\n                  >\n                    {gradient?.length ? (\n                      <defs>\n                        <linearGradient id={gradientId} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n                          {gradient.map((role, index) => (\n                            <stop\n                              key={`${role}-${index}`}\n                              offset={gradient.length === 1 ? 0 : index / (gradient.length - 1)}\n                              style={{ stopColor: COLOR_VAR[role] }}\n                            />\n                          ))}\n                        </linearGradient>\n                      </defs>\n                    ) : null}\n                    {variant === 'dots' || variant === 'ring' ? layer('track', { stroke: track }) : null}\n                    {variant === 'radial' ? (\n                      <circle\n                        cx={h / 2}\n                        cy={h / 2}\n                        r={h * HOLE}\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeOpacity={0.22}\n                        strokeWidth={1.5}\n                      />\n                    ) : null}\n                    {variant === 'radial' ? layer('tip', { opacity: 0.42 }) : null}\n                    {variant === 'dots' || variant === 'ring' ? layer('edge', { opacity: 0.45 }) : null}\n                    {variant === 'bars' || variant === 'dots'\n                      ? layer('rest', trackColor && variant === 'bars' ? { stroke: track } : { opacity: UNPLAYED })\n                      : null}\n                    {layer('ink')}\n                  </svg>\n                ) : null}\n                {children}\n              </>\n            )}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nSoundwaveRoot.displayName = 'Soundwave';\n\nexport interface SoundwaveCenterProps extends ComponentProps<'div'> {}\n\n/**\n * What sits in the hole of a `radial` or `ring` wave: a person's avatar, a\n * figure, a button. It is sized to the hole and centred in it.\n */\nfunction SoundwaveCenter({ className, render, ...props }: SoundwaveCenterProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ 'data-slot': 'soundwave-center', className: soundwaveVariants().center({ className }) }, props),\n  });\n}\nSoundwaveCenter.displayName = 'Soundwave.Center';\n\nexport const Soundwave = Object.assign(SoundwaveRoot, { Center: SoundwaveCenter });\n\nexport { SoundwaveCenter };\n\n/* -------------------------------------------------------------------------- */\n/* Audio                                                                      */\n/* -------------------------------------------------------------------------- */\n\n/** Something with sound in it: a microphone or call stream, or an `<audio>` or `<video>` element. */\nexport type SoundwaveSource = MediaStream | HTMLMediaElement;\n\nexport interface AudioLevelOptions {\n  /**\n   * The quietest sound that still moves the wave, in decibels below full\n   * scale. Default `-60`. Raise it towards `-45` in a noisy room.\n   */\n  floor?: number;\n  /** The loudness that fills the wave, in decibels below full scale. Default `-12`. */\n  ceiling?: number;\n}\n\nlet sharedContext: AudioContext | null = null;\n\n/** One audio context for every wave on the page. Browsers limit how many may run. */\nfunction audioContext(): AudioContext | null {\n  if (typeof window === 'undefined') return null;\n  if (!sharedContext) {\n    const Context =\n      window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n    if (!Context) return null;\n    sharedContext = new Context();\n  }\n  // Created or resumed outside a click, it starts suspended. Asking again is harmless.\n  if (sharedContext.state === 'suspended') void sharedContext.resume().catch(() => {});\n  return sharedContext;\n}\n\n/*\n * An element can be routed into an audio graph only once, so its node is\n * kept. The node is wired to the speakers as well as to the analyser, since\n * routing an element through the graph otherwise takes its sound away.\n */\nconst elementNodes = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>();\n\nfunction sourceNode(context: AudioContext, source: SoundwaveSource): AudioNode {\n  if (typeof MediaStream !== 'undefined' && source instanceof MediaStream) {\n    // Not wired to the speakers: a microphone played back is feedback.\n    return context.createMediaStreamSource(source);\n  }\n  const element = source as HTMLMediaElement;\n  let node = elementNodes.get(element);\n  if (!node) {\n    node = context.createMediaElementSource(element);\n    node.connect(context.destination);\n    elementNodes.set(element, node);\n  }\n  return node;\n}\n\n/**\n * How loud `source` is, from 0 to 1, kept in a ref that is updated every\n * frame. Pass the ref to `Soundwave` as `level`, or read `ref.current`\n * yourself. With no source the ref holds 0.\n */\nexport function useAudioLevel(\n  source: SoundwaveSource | null | undefined,\n  { floor = -60, ceiling = -12 }: AudioLevelOptions = {}\n): RefObject<number> {\n  const level = useRef(0);\n  useEffect(() => {\n    level.current = 0;\n    if (!source) return;\n    const context = audioContext();\n    if (!context) return;\n    let input: AudioNode;\n    try {\n      input = sourceNode(context, source);\n    } catch {\n      return;\n    }\n    const analyser = context.createAnalyser();\n    analyser.fftSize = 1024;\n    input.connect(analyser);\n    const samples = new Float32Array(analyser.fftSize);\n    const resume = () => void context.resume().catch(() => {});\n    const element =\n      typeof MediaStream !== 'undefined' && source instanceof MediaStream ? null : (source as HTMLMediaElement);\n    element?.addEventListener('play', resume);\n    let frame = 0;\n    const read = () => {\n      analyser.getFloatTimeDomainData(samples);\n      let sum = 0;\n      for (let i = 0; i < samples.length; i++) sum += samples[i]! * samples[i]!;\n      // Loudness in decibels is negative and logarithmic; the wave wants 0 to 1.\n      const db = 20 * Math.log10(Math.sqrt(sum / samples.length) || 1e-9);\n      level.current = clamp01((db - floor) / (ceiling - floor));\n      frame = requestAnimationFrame(read);\n    };\n    read();\n    return () => {\n      cancelAnimationFrame(frame);\n      element?.removeEventListener('play', resume);\n      try {\n        input.disconnect(analyser);\n      } catch {\n        // Already gone with its context.\n      }\n      if (!element) input.disconnect();\n      level.current = 0;\n    };\n  }, [source, floor, ceiling]);\n  return level;\n}\n\nexport type MicrophoneStatus = 'idle' | 'requesting' | 'listening' | 'denied' | 'unsupported';\n\nexport interface UseMicrophoneOptions extends AudioLevelOptions {\n  /** Passed to `getUserMedia` as `audio`, to pick a device or change its processing. */\n  constraints?: MediaTrackConstraints;\n}\n\nexport interface MicrophoneControls {\n  /** How loud the microphone is, from 0 to 1. Pass it to `Soundwave` as `level`. */\n  level: RefObject<number>;\n  /** The live stream while listening, for a recorder or a call. `null` otherwise. */\n  stream: MediaStream | null;\n  /** `requesting` while the browser asks for permission, `denied` if it was refused or there is no microphone. */\n  status: MicrophoneStatus;\n  /** Whether it is listening now. */\n  active: boolean;\n  /** Ask for the microphone and start listening. Call it from a click: browsers only allow sound to start from one. */\n  start: () => Promise<void>;\n  /** Stop listening and release the microphone, which turns the browser's recording light off. */\n  stop: () => void;\n  /** `start` when stopped, `stop` when listening. */\n  toggle: () => void;\n}\n\n/**\n * The microphone, ready to draw. Nothing is asked for until `start`, and the\n * microphone is released on `stop` and when the component unmounts.\n *\n * ```tsx\n * const mic = useMicrophone();\n * <Soundwave level={mic.active ? mic.level : undefined} />\n * <Button onClick={mic.toggle}>{mic.active ? 'Stop' : 'Speak'}</Button>\n * ```\n */\nexport function useMicrophone({ constraints, ...options }: UseMicrophoneOptions = {}): MicrophoneControls {\n  const [stream, setStream] = useState<MediaStream | null>(null);\n  const [status, setStatus] = useState<MicrophoneStatus>('idle');\n  const level = useAudioLevel(stream, options);\n  const current = useRef<MediaStream | null>(null);\n  const asking = useRef(false);\n\n  const stop = () => {\n    current.current?.getTracks().forEach((track) => track.stop());\n    current.current = null;\n    setStream(null);\n    setStatus((value) => (value === 'listening' || value === 'requesting' ? 'idle' : value));\n  };\n\n  const start = async () => {\n    if (current.current || asking.current) return;\n    if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {\n      setStatus('unsupported');\n      return;\n    }\n    asking.current = true;\n    setStatus('requesting');\n    // Made here, inside the click, so the browser lets it run.\n    audioContext();\n    try {\n      const next = await navigator.mediaDevices.getUserMedia({\n        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, ...constraints },\n      });\n      current.current = next;\n      setStream(next);\n      setStatus('listening');\n    } catch {\n      setStatus('denied');\n    } finally {\n      asking.current = false;\n    }\n  };\n\n  useEffect(() => () => current.current?.getTracks().forEach((track) => track.stop()), []);\n\n  const active = status === 'listening' && stream !== null;\n  return { level, stream, status, active, start, stop, toggle: () => (active ? stop() : void start()) };\n}\n"
    }
  ]
}
