{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voiceprint",
  "type": "registry:ui",
  "title": "Voiceprint",
  "description": "A background that prints a voice as it speaks. Sound comes in at the middle as a stroke of dashes as tall as it is loud, then travels out to both edges, so a sentence spreads across the section like a pressed waveform. Each speaker prints in their own color, and in silence it settles back into a calm dot grid.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "background",
    "ai",
    "canvas"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/voiceprint",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/voiceprint.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Every frame runs in `createPrint`, outside React: one rAF loop draws to a canvas and reads a\n *   function `level` each frame, and it sleeps once the last sound has left both edges, off\n *   screen, or in a hidden tab.\n * - Nothing moves: each column shows the loudest history sample from as long ago as sound takes\n *   to reach it at `speed`, so sound travels out mirrored, and each sample keeps its tone.\n * - Marks are batched into one stroke per tone and opacity step, and the resting grid is drawn\n *   once per size and color, then copied under every frame.\n * - A `source` runs through a noise gate (a floor that drops fast and creeps up slowly, shut while\n *   it warms up), so tape hiss and room tone never print.\n * - Reduced motion keeps the print still: a band around the middle takes the speaker's color by\n *   opacity alone, read from a `useSyncExternalStore` flag that's false while hydrating.\n */\nconst TIMING = {\n  /** What was said traveling out to both edges, in px/s (the `speed` prop). */\n  speed: 90,\n  /** The level rising to a new sound, as a time constant in seconds (the `attack` prop). */\n  attack: 0.03,\n  /** The level falling away after a sound, as a time constant in seconds (the `release` prop). */\n  release: 0.14,\n  /** History samples per second; each column reads the past from these. */\n  sampleRate: 60,\n  /** The noise floor dropping to a quieter moment, as a time constant in seconds. */\n  noiseFall: 0.08,\n  /** The noise floor creeping up under steady sound, as a time constant in seconds. */\n  noiseRise: 3,\n  /** How fast it rises while it learns the hiss, as a time constant in seconds. */\n  noiseLearn: 0.15,\n  /** How long a new source learns its hiss, in seconds. */\n  learn: 1,\n  /** The gate staying shut when a source starts, so the first hiss never prints, in seconds. */\n  warmup: 0.3,\n  /** Between level checks while asleep, in ms. */\n  poll: 100,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * VOICEPRINT STORYBOARD\n *\n *  rest     a calm grid of faint dots, one every `spacing`\n *           px, fading out toward the edges\n *  speak    sound comes in at the middle column. Dots inside\n *           its loudness envelope (above and below the middle\n *           line, up to `reach` of half the height) stretch\n *           into short vertical dashes in the speaker's color.\n *           The level rises in 30ms and falls in 140ms, so a\n *           syllable prints as one stroke\n *  travel   what was said moves outward to both edges at\n *           `speed` px/s, mirrored, so a sentence spreads\n *           across the surface like a pressed waveform. Sound\n *           loses 60% of its ink on the way to the edge\n *  turn     `tone` switches speakers. Sound already printed\n *           keeps the color it was spoken in, so a call reads\n *           as two colors leaving the middle\n *  listen   a `source` runs through a noise gate: the floor\n *           drops to any quieter moment in 80ms and creeps up\n *           under steady sound over 3s (0.15s in the first\n *           second, while it learns the hiss), and the gate is\n *           shut for the first 0.3s, so tape hiss and room\n *           tone never print\n *  silence  once the last sound has left both edges the field\n *           is back at rest, and the loop sleeps until the\n *           level rises again (checked every 100ms)\n *  paused   the print holds still where it is and the level\n *           isn't read\n *  sleep    off screen or in a hidden tab nothing draws, and\n *           the print picks up where it stopped\n *  reduced  nothing travels: the dots in a band around the\n *           middle take on the speaker's color as loud as the\n *           voice is, by opacity alone, and go back to rest in\n *           silence\n * ───────────────────────────────────────────────────────── */\n\nconst VOICEPRINT = {\n  dot: 1.6, // px, diameter of a resting dot and the width of a faint dash\n  thicken: 0.45, // a dash at full ink is this much wider than a dot\n  dash: 0.5, // a dash at full depth is this share of `spacing` long; shorter keeps the grid showing through\n  rest: 0.18, // opacity of a resting dot\n  soft: 1.4, // spacings over which a dot inside the envelope grows into a full dash\n  history: 4096, // samples kept: 68s at 60 a second, enough for any width at slow speeds\n  ageFade: 0.6, // share of its ink a sound has lost when it reaches the edge (`falloff`)\n  edge: 0.14, // share of the width the field fades out over at each side\n  edgeY: 0.06, // share of the height it fades out over at the top and bottom\n  silent: 0.02, // a level under this counts as silence\n  levels: 10, // opacity steps the marks are batched into\n  gain: 1.8, // microphone and media loudness: sqrt(rms over the noise floor) × gain …\n  floor: 0.04, // … minus this\n  gate: [1.5, 2.3], // loudness over the noise floor, as a ratio, from gate shut to fully open\n  fft: 1024, // analyser window, in samples\n  band: [0.06, 0.3], // reduced: the lit band fades out this far from the middle, as shares of the width\n  bandY: [0.08, 0.3], // and of the height\n  bandInk: 0.9, // reduced: opacity of the band's dots at full loudness\n  maxDpr: 2,\n} as const;\n\ntype Tone = \"accent\" | \"ink\";\ntype Level = number | (() => number);\n\ntype Options = {\n  level: Level;\n  tone: Tone;\n  speed: number;\n  spacing: number;\n  reach: number;\n  attack: number;\n  release: number;\n  falloff: number;\n  paused: boolean;\n  reduced: boolean;\n};\n\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\nconst smoothstep = (a: number, b: number, x: number) => {\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n};\n\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(REDUCED);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\n\n/** Reduced motion from the media query, false on the server and in the hydration render. */\nfunction useReduced() {\n  return React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(REDUCED).matches,\n    () => false,\n  );\n}\n\n/* ── Listening ─────────────────────────────────────────── */\n// A media element can feed only one source node, ever, and its sound then\n// plays through that node's context. So elements share one context that is\n// never closed (closing it would mute them), wired on the element's first\n// play so no context is made before a gesture. Streams get their own.\n\ntype Ear = {\n  analyser: AnalyserNode | null; // null until a media element first plays\n  data: Float32Array<ArrayBuffer>;\n  noise: number; // running estimate of the source's noise floor (rms), -1 before the first reading\n  heard: number; // s of sound read since the source started\n  idle: () => boolean; // a paused element: its silence says nothing about its noise\n  release: () => void;\n};\n\nlet mediaContext: AudioContext | null = null;\nconst mediaNodes = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>();\n\nfunction createAnalyser(context: AudioContext) {\n  const analyser = context.createAnalyser();\n  analyser.fftSize = VOICEPRINT.fft;\n  return analyser;\n}\n\nfunction listen(source: HTMLMediaElement | MediaStream): Ear | null {\n  if (typeof window.AudioContext === \"undefined\") return null;\n  const ear: Ear = {\n    analyser: null,\n    data: new Float32Array(VOICEPRINT.fft),\n    noise: -1,\n    heard: 0,\n    idle: () => false,\n    release: () => {},\n  };\n  try {\n    if (source instanceof MediaStream) {\n      if (source.getAudioTracks().length === 0) return null;\n      const context = new AudioContext();\n      const node = context.createMediaStreamSource(source);\n      const analyser = createAnalyser(context);\n      // Not connected to the speakers: a microphone would echo.\n      node.connect(analyser);\n      void context.resume();\n      ear.analyser = analyser;\n      ear.release = () => {\n        node.disconnect();\n        void context.close();\n      };\n      return ear;\n    }\n\n    const element = source;\n    let node: MediaElementAudioSourceNode | null = null;\n    let analyser: AnalyserNode | null = null;\n    const wire = () => {\n      ear.noise = -1;\n      ear.heard = 0;\n      try {\n        mediaContext ??= new AudioContext();\n        void mediaContext.resume();\n        if (analyser) return;\n        node = mediaNodes.get(element) ?? null;\n        if (!node) {\n          node = mediaContext.createMediaElementSource(element);\n          node.connect(mediaContext.destination);\n          mediaNodes.set(element, node);\n        }\n        analyser = createAnalyser(mediaContext);\n        node.connect(analyser);\n        ear.analyser = analyser;\n      } catch {\n        // Already wired into another context: it reads as silence.\n      }\n    };\n    if (!element.paused) wire();\n    element.addEventListener(\"play\", wire);\n    ear.idle = () => element.paused;\n    ear.release = () => {\n      element.removeEventListener(\"play\", wire);\n      if (!node || !analyser) return;\n      try {\n        node.disconnect(analyser);\n      } catch {\n        // already disconnected\n      }\n    };\n    return ear;\n  } catch {\n    return null;\n  }\n}\n\nfunction loudness(ear: Ear, dt: number) {\n  const analyser = ear.analyser;\n  if (!analyser || ear.idle()) return 0;\n  analyser.getFloatTimeDomainData(ear.data);\n  let sum = 0;\n  for (let i = 0; i < ear.data.length; i++) sum += ear.data[i] * ear.data[i];\n  const rms = Math.sqrt(sum / ear.data.length);\n  // A noise floor that drops at once to a quieter moment and creeps up under\n  // steady sound, so tape hiss and room tone sit under it and read as silence.\n  ear.heard += dt;\n  if (ear.noise < 0) ear.noise = rms;\n  else {\n    const rise = ear.heard < TIMING.learn ? TIMING.noiseLearn : TIMING.noiseRise;\n    const tau = rms < ear.noise ? TIMING.noiseFall : rise;\n    ear.noise += (rms - ear.noise) * (1 - Math.exp(-dt / tau));\n  }\n  if (ear.heard < TIMING.warmup) return 0;\n  const open = smoothstep(VOICEPRINT.gate[0], VOICEPRINT.gate[1], rms / Math.max(ear.noise, 1e-4));\n  return clamp01(Math.sqrt(Math.max(0, rms - ear.noise)) * VOICEPRINT.gain - VOICEPRINT.floor) * open;\n}\n\n/* ── Print ─────────────────────────────────────────────── */\n// Everything that runs per frame, outside React.\n\nfunction createPrint() {\n  let root: HTMLElement | null = null;\n  let canvas: HTMLCanvasElement | null = null;\n  let ctx: CanvasRenderingContext2D | null = null;\n  let probes: { ink: HTMLElement; accent: HTMLElement } | null = null;\n  let colors: [string, string] = [\"#18181b\", \"#2747ff\"]; // by tone index: 0 ink, 1 accent\n  let restLayer: HTMLCanvasElement | null = null;\n\n  let options: Options = {\n    level: 0,\n    tone: \"accent\",\n    speed: TIMING.speed,\n    spacing: 10,\n    reach: 0.9,\n    attack: TIMING.attack,\n    release: TIMING.release,\n    falloff: VOICEPRINT.ageFade,\n    paused: false,\n    reduced: false,\n  };\n  let ear: Ear | null = null;\n\n  // The grid, rebuilt when the root changes size.\n  let width = 0;\n  let height = 0;\n  let dpr = 1;\n  let cols = 0;\n  let rows = 0;\n  let ox = 0;\n  let oy = 0;\n  let colAge = new Float32Array(0); // s back in time each column shows\n  let colFade = new Float32Array(0);\n  let rowFade = new Float32Array(0);\n  let maxAge = 0; // s for a sound to leave the edge\n  let slot = 0; // s of history one column covers\n\n  // Marks to stroke this frame: x, y, length, batched by tone × opacity step.\n  let marks: Float32Array[] = [];\n  let markCount = new Int32Array(0);\n\n  // History: one loudness and speaker per sample, newest at head - 1.\n  const N = VOICEPRINT.history;\n  const DT = 1 / TIMING.sampleRate;\n  const levels = new Float32Array(N);\n  const tones = new Uint8Array(N);\n  let head = 0;\n  let sampledAt = 0;\n  let lastLoud = -Infinity;\n  let smooth = 0;\n  let lastT = 0;\n\n  let raf = 0;\n  let poll = 0;\n  let visible = false;\n  let hidden = false;\n  const cleanups: (() => void)[] = [];\n\n  function readLevel(dt: number) {\n    if (ear) return loudness(ear, dt);\n    const level = options.level;\n    const value = typeof level === \"function\" ? level() : level;\n    return Number.isFinite(value) ? clamp01(value) : 0;\n  }\n\n  function readColors() {\n    if (!probes) return;\n    colors = [getComputedStyle(probes.ink).color, getComputedStyle(probes.accent).color];\n  }\n\n  function push(tone: number, alpha: number, x: number, y: number, length: number) {\n    const step = Math.min(VOICEPRINT.levels - 1, Math.floor(alpha * VOICEPRINT.levels));\n    if (step < 0) return;\n    const b = tone * VOICEPRINT.levels + step;\n    const n = markCount[b];\n    const data = marks[b];\n    if (!data || n * 3 + 2 >= data.length) return;\n    data[n * 3] = x;\n    data[n * 3 + 1] = y;\n    data[n * 3 + 2] = length;\n    markCount[b] = n + 1;\n  }\n\n  function flush(target: CanvasRenderingContext2D, thicken: boolean) {\n    target.lineCap = \"round\";\n    for (let tone = 0; tone < 2; tone++) {\n      for (let step = 0; step < VOICEPRINT.levels; step++) {\n        const b = tone * VOICEPRINT.levels + step;\n        const n = markCount[b];\n        if (!n) continue;\n        const data = marks[b];\n        const share = step / (VOICEPRINT.levels - 1);\n        target.globalAlpha = (step + 1) / VOICEPRINT.levels;\n        target.strokeStyle = colors[tone];\n        target.lineWidth = VOICEPRINT.dot * (thicken ? 1 + VOICEPRINT.thicken * share : 1);\n        target.beginPath();\n        for (let i = 0; i < n; i++) {\n          const x = data[i * 3];\n          const y = data[i * 3 + 1];\n          // A zero-length segment with round caps draws a dot.\n          const half = data[i * 3 + 2] / 2 + 0.01;\n          target.moveTo(x, y - half);\n          target.lineTo(x, y + half);\n        }\n        target.stroke();\n      }\n    }\n    target.globalAlpha = 1;\n    markCount.fill(0);\n  }\n\n  /** The resting grid, drawn once per size and color, then copied under every frame. */\n  function drawRest() {\n    if (!canvas || !width) return;\n    restLayer ??= document.createElement(\"canvas\");\n    restLayer.width = canvas.width;\n    restLayer.height = canvas.height;\n    const rest = restLayer.getContext(\"2d\");\n    if (!rest) return;\n    rest.setTransform(dpr, 0, 0, dpr, 0, 0);\n    rest.clearRect(0, 0, width, height);\n    markCount.fill(0);\n    const s = options.spacing;\n    for (let c = 0; c < cols; c++) {\n      if (colFade[c] < 0.02) continue;\n      for (let r = 0; r < rows; r++) {\n        push(0, VOICEPRINT.rest * colFade[c] * rowFade[r], ox + c * s, oy + r * s, 0);\n      }\n    }\n    flush(rest, false);\n  }\n\n  function layout(force = false) {\n    if (!root || !canvas) return;\n    const w = root.clientWidth;\n    const h = root.clientHeight;\n    dpr = Math.min(window.devicePixelRatio || 1, VOICEPRINT.maxDpr);\n    if (!force && w === width && h === height && canvas.width === Math.round(w * dpr)) return;\n    width = w;\n    height = h;\n    canvas.width = Math.round(w * dpr);\n    canvas.height = Math.round(h * dpr);\n\n    const s = options.spacing;\n    const speed = Math.max(1, options.speed);\n    cols = Math.max(1, Math.floor(w / s));\n    rows = Math.max(1, Math.floor(h / s));\n    ox = (w - (cols - 1) * s) / 2;\n    oy = (h - (rows - 1) * s) / 2;\n    colAge = new Float32Array(cols);\n    colFade = new Float32Array(cols);\n    rowFade = new Float32Array(rows);\n    for (let c = 0; c < cols; c++) {\n      const x = ox + c * s;\n      const xn = x / w;\n      colAge[c] = Math.abs(x - w / 2) / speed;\n      colFade[c] = smoothstep(0, VOICEPRINT.edge, xn) * smoothstep(0, VOICEPRINT.edge, 1 - xn);\n    }\n    for (let r = 0; r < rows; r++) {\n      const yn = (oy + r * s) / h;\n      rowFade[r] = smoothstep(0, VOICEPRINT.edgeY, yn) * smoothstep(0, VOICEPRINT.edgeY, 1 - yn);\n    }\n    slot = s / speed;\n    maxAge = w / 2 / speed + slot;\n    marks = Array.from({ length: 2 * VOICEPRINT.levels }, () => new Float32Array(cols * rows * 3));\n    markCount = new Int32Array(2 * VOICEPRINT.levels);\n    drawRest();\n  }\n\n  /** Dashes for every column, each showing the loudest moment of its slice of the past. */\n  function collectPrint() {\n    const s = options.spacing;\n    const mid = height / 2;\n    const half = (height / 2) * options.reach;\n    const soft = VOICEPRINT.soft * s;\n    const oldest = head - N;\n    for (let c = 0; c < cols; c++) {\n      const fade = colFade[c];\n      if (fade < 0.02) continue;\n      const age = colAge[c];\n      const from = Math.max(0, Math.floor((age - slot / 2) / DT));\n      const to = Math.ceil((age + slot / 2) / DT);\n      let level = 0;\n      let tone = 0;\n      for (let k = from; k <= to; k++) {\n        const i = head - 1 - k;\n        if (i < 0 || i < oldest) break;\n        const v = levels[i % N];\n        if (v > level) {\n          level = v;\n          tone = tones[i % N];\n        }\n      }\n      if (level < VOICEPRINT.silent) continue;\n      const reach = level * half;\n      if (reach < s * 0.3) continue;\n      const ink = fade * (1 - options.falloff * Math.min(1, age / maxAge));\n      const x = ox + c * s;\n      const r0 = Math.max(0, Math.ceil((mid - reach - soft - oy) / s));\n      const r1 = Math.min(rows - 1, Math.floor((mid + reach + soft - oy) / s));\n      for (let r = r0; r <= r1; r++) {\n        const y = oy + r * s;\n        const depth = smoothstep(-0.3 * s, soft, reach - Math.abs(y - mid));\n        if (depth < 0.03) continue;\n        const alpha = (VOICEPRINT.rest + (1 - VOICEPRINT.rest) * depth) * ink * rowFade[r];\n        push(tone, alpha, x, y, depth * VOICEPRINT.dash * s);\n      }\n    }\n  }\n\n  /** Reduced: the band around the middle takes the speaker's color as loud as the voice is. */\n  function collectBand() {\n    if (smooth < VOICEPRINT.silent) return;\n    const s = options.spacing;\n    const tone = options.tone === \"accent\" ? 1 : 0;\n    const [x0, x1] = VOICEPRINT.band;\n    const [y0, y1] = VOICEPRINT.bandY;\n    for (let c = 0; c < cols; c++) {\n      const x = ox + c * s;\n      const bx = 1 - smoothstep(x0, x1, Math.abs(x / width - 0.5));\n      if (bx < 0.02) continue;\n      for (let r = 0; r < rows; r++) {\n        const y = oy + r * s;\n        const k = smooth * bx * (1 - smoothstep(y0, y1, Math.abs(y / height - 0.5)));\n        if (k < 0.02) continue;\n        const alpha = (VOICEPRINT.rest + (VOICEPRINT.bandInk - VOICEPRINT.rest) * k) * colFade[c] * rowFade[r];\n        push(tone, alpha, x, y, 0);\n      }\n    }\n  }\n\n  function draw() {\n    if (!ctx || !canvas || !width) return;\n    ctx.setTransform(1, 0, 0, 1, 0, 0);\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n    if (restLayer) ctx.drawImage(restLayer, 0, 0);\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    if (options.reduced) collectBand();\n    else collectPrint();\n    flush(ctx, !options.reduced);\n  }\n\n  function sampleTo(t: number) {\n    // Coming back from sleep: carry on from here instead of replaying the gap.\n    if (t - sampledAt > 0.25) sampledAt = t - DT;\n    const tone = options.tone === \"accent\" ? 1 : 0;\n    while (sampledAt + DT <= t) {\n      const i = head % N;\n      levels[i] = smooth;\n      tones[i] = tone;\n      if (smooth >= VOICEPRINT.silent) lastLoud = head;\n      head++;\n      sampledAt += DT;\n    }\n  }\n\n  function quiet() {\n    if (smooth >= VOICEPRINT.silent) return false;\n    if (options.reduced) return true;\n    return (head - 1 - lastLoud) * DT > maxAge + slot;\n  }\n\n  function frame(ms: number) {\n    raf = 0;\n    if (!ctx || !visible || hidden) return;\n    const t = ms / 1000;\n    const dt = Math.min(0.1, Math.max(0, t - lastT));\n    lastT = t;\n    if (options.paused) {\n      draw();\n      return;\n    }\n    const target = readLevel(dt);\n    const tau = target > smooth ? options.attack : options.release;\n    smooth += (target - smooth) * (1 - Math.exp(-dt / tau));\n    if (smooth < 1e-4) smooth = 0;\n    if (!options.reduced) sampleTo(t);\n    draw();\n    if (quiet()) {\n      schedulePoll();\n      return;\n    }\n    raf = requestAnimationFrame(frame);\n  }\n\n  function schedulePoll() {\n    window.clearTimeout(poll);\n    poll = window.setTimeout(() => {\n      poll = 0;\n      if (!visible || hidden || options.paused) return;\n      if (readLevel(TIMING.poll / 1000) >= VOICEPRINT.silent) wake();\n      else schedulePoll();\n    }, TIMING.poll);\n  }\n\n  function wake() {\n    if (raf || !ctx || !visible || hidden) return;\n    window.clearTimeout(poll);\n    poll = 0;\n    lastT = performance.now() / 1000;\n    raf = requestAnimationFrame(frame);\n  }\n\n  function clearHistory() {\n    levels.fill(0);\n    head = 0;\n    lastLoud = -Infinity;\n  }\n\n  return {\n    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; ink: HTMLElement; accent: HTMLElement }) {\n      root = el.root;\n      canvas = el.canvas;\n      ctx = canvas.getContext(\"2d\");\n      probes = { ink: el.ink, accent: el.accent };\n      readColors();\n      layout(true);\n      draw();\n\n      const resize = new ResizeObserver(() => {\n        layout();\n        draw();\n      });\n      resize.observe(root);\n      const io = new IntersectionObserver(([entry]) => {\n        visible = entry.isIntersecting;\n        if (visible) wake();\n      });\n      io.observe(root);\n      const onVisibility = () => {\n        hidden = document.hidden;\n        if (!hidden) wake();\n      };\n      document.addEventListener(\"visibilitychange\", onVisibility);\n\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      const recolor = () => {\n        readColors();\n        drawRest();\n        draw();\n      };\n      scheme.addEventListener(\"change\", recolor);\n      const themeAttrs = new MutationObserver(recolor);\n      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n\n      cleanups.push(\n        () => resize.disconnect(),\n        () => io.disconnect(),\n        () => document.removeEventListener(\"visibilitychange\", onVisibility),\n        () => scheme.removeEventListener(\"change\", recolor),\n        () => themeAttrs.disconnect(),\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        window.clearTimeout(poll);\n        poll = 0;\n        cleanups.splice(0).forEach((fn) => fn());\n        ear?.release();\n        ear = null;\n        root = canvas = null;\n        ctx = null;\n        probes = null;\n        restLayer = null;\n        width = height = 0;\n      };\n    },\n\n    setOptions(next: Options) {\n      const relayout = next.spacing !== options.spacing || next.speed !== options.speed;\n      const switched = next.reduced !== options.reduced;\n      options = next;\n      if (switched) {\n        clearHistory();\n        smooth = 0;\n      }\n      if (relayout) layout(true);\n      draw();\n      wake();\n    },\n\n    setSource(source: HTMLMediaElement | MediaStream | null) {\n      ear?.release();\n      ear = source ? listen(source) : null;\n      wake();\n    },\n  };\n}\n\n/* ── Voiceprint ────────────────────────────────────────── */\n\nexport interface VoiceprintProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Loudness now, from 0 to 1. Pass a function to have it read every frame, so a live level never re-renders your component. */\n  level?: number | (() => number);\n  /** An `<audio>` or `<video>` element, or a `MediaStream` (a microphone, a call), to listen to. While it's given, its loudness replaces `level`. A cross-origin file reads as silence unless it's served with CORS. */\n  source?: HTMLMediaElement | MediaStream | null;\n  /** Who is speaking now: the accent color or the ink color. Sound already printed keeps the color it was spoken in. */\n  tone?: Tone;\n  /** How fast what was said travels out to the edges, in px per second. */\n  speed?: number;\n  /** Distance between dots, in px. */\n  spacing?: number;\n  /** How far the loudest sound reaches from the middle line, as a share of half the height. */\n  reach?: number;\n  /** Seconds the level takes to rise to a new sound (a time constant). Longer softens the start of each stroke. */\n  attack?: number;\n  /** Seconds the level takes to fall away after a sound (a time constant). Longer smears syllables together. */\n  release?: number;\n  /** Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). */\n  falloff?: number;\n  /** Hold the print still where it is and stop reading the level. */\n  paused?: boolean;\n  /** Classes that set the resting dots and the `ink` speaker's color (a `text-*` color). */\n  inkClassName?: string;\n  /** Classes that set the `accent` speaker's color (a `text-*` color). */\n  accentClassName?: string;\n}\n\nexport function Voiceprint({\n  level = 0,\n  source = null,\n  tone = \"accent\",\n  speed = TIMING.speed,\n  spacing = 10,\n  reach = 0.9,\n  attack = TIMING.attack,\n  release = TIMING.release,\n  falloff = VOICEPRINT.ageFade,\n  paused = false,\n  inkClassName,\n  accentClassName,\n  className,\n  children,\n  ...props\n}: VoiceprintProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const inkRef = React.useRef<HTMLSpanElement>(null);\n  const accentRef = React.useRef<HTMLSpanElement>(null);\n  const [print] = React.useState(createPrint);\n  const reduced = useReduced();\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ink = inkRef.current;\n    const accent = accentRef.current;\n    if (!root || !canvas || !ink || !accent) return;\n    return print.connect({ root, canvas, ink, accent });\n  }, [print]);\n\n  React.useEffect(() => {\n    print.setOptions({\n      level,\n      tone,\n      speed,\n      spacing,\n      reach,\n      attack: Math.max(0.001, attack),\n      release: Math.max(0.001, release),\n      falloff: Math.min(1, Math.max(0, falloff)),\n      paused,\n      reduced,\n    });\n  }, [print, level, tone, speed, spacing, reach, attack, release, falloff, paused, reduced]);\n\n  React.useEffect(() => {\n    print.setSource(source);\n    return () => print.setSource(null);\n  }, [print, source]);\n\n  return (\n    <div ref={rootRef} className={cn(\"relative isolate overflow-hidden\", className)} {...props}>\n      <canvas ref={canvasRef} aria-hidden className=\"pointer-events-none absolute inset-0 -z-10 size-full\" />\n      {/* Probes the canvas reads its colors from. Defaults apply only when no class is given, so a `dark:` default can't win over yours. */}\n      <span ref={inkRef} aria-hidden className={cn(\"hidden\", inkClassName ?? \"text-zinc-900 dark:text-zinc-100\")} />\n      <span ref={accentRef} aria-hidden className={cn(\"hidden\", accentClassName ?? \"text-[#2747ff] dark:text-[#6b84ff]\")} />\n      {children}\n    </div>\n  );\n}\n"
    }
  ]
}