{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-note","type":"registry:component","title":"Voice note","description":"A voice message you can scrub and read along with: the waveform fills, the time rolls, and the transcript highlights each word as it’s said.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/voice-note.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * VOICE NOTE: a voice message you can read along with\n *\n *   arrive    the waveform grows out of a flat line, bar after\n *             bar, left to right\n *   play      the triangle splits and squares off into pause (one\n *             shape, two halves); bars fill as it plays and the\n *             time rolls up\n *   scrub     press anywhere on the waveform and drag: the bars\n *             under your finger swell like a lens and the time\n *             follows; let go and it carries on from there\n *   speed     1× rolls to 1.5× and 2×\n *   read      \"Transcript\" folds the words open under the note; a\n *             highlight glides from word to word as they're said,\n *             the words ahead wait in grey, and tapping a word\n *             plays from it\n *   end       it holds full for a beat, then the fill runs back\n *             and pause turns back into play\n *\n * Only one voice note plays at a time on a page.\n * ───────────────────────────────────────────────────────── */\n\nexport interface VoiceNoteWord {\n  /** The word as written, punctuation included. */\n  text: string;\n  /** When it's said, in seconds. */\n  start: number;\n}\n\nexport interface VoiceNoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  src: string;\n  /** Loudness from 0 to 1, any length; worked out from the audio when left out. */\n  peaks?: number[];\n  /** Seconds, shown before the audio's own length is known. */\n  duration?: number;\n  transcript?: VoiceNoteWord[];\n  defaultTranscriptOpen?: boolean;\n  speeds?: number[];\n  /** Mark it as not listened to yet. */\n  unread?: boolean;\n  /** What it is, for assistive tech, e.g. \"Voice note from Sam\". */\n  label?: string;\n  onPlay?: () => void;\n  onEnded?: () => void;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst BAR = 3;\nconst GAP = 2;\nconst PLAY_EVENT = \"hairline:voice-note-play\";\n\n// Play and pause as the same two quads: the halves of a triangle, or two bars.\nconst SHAPES = {\n  play: [\"polygon(30% 12%, 58% 30%, 58% 70%, 30% 88%)\", \"polygon(58% 30%, 88% 50%, 88% 50%, 58% 70%)\"],\n  pause: [\"polygon(22% 14%, 42% 14%, 42% 86%, 22% 86%)\", \"polygon(58% 14%, 78% 14%, 78% 86%, 58% 86%)\"],\n};\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst clock = (t: number) => {\n  const s = Math.max(0, Math.round(t));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n};\n\n/** Squeeze or stretch the peaks to n bars, keeping each stretch's loudest moment. */\nfunction resample(source: number[], n: number) {\n  if (!source.length) return Array.from({ length: n }, () => 0);\n  return Array.from({ length: n }, (_, i) => {\n    const a = Math.floor((i * source.length) / n);\n    const b = Math.max(a + 1, Math.floor(((i + 1) * source.length) / n));\n    let top = 0;\n    for (let k = a; k < b && k < source.length; k++) top = Math.max(top, source[k]);\n    return top;\n  });\n}\n\n/** Loudness peaks straight from the audio file, when none were given. */\nasync function decodePeaks(src: string, n = 128, signal?: AbortSignal) {\n  const data = await (await fetch(src, { signal })).arrayBuffer();\n  const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;\n  const ctx = new Ctx();\n  try {\n    const audio = await ctx.decodeAudioData(data);\n    const channel = audio.getChannelData(0);\n    const size = channel.length / n;\n    const rms = Array.from({ length: n }, (_, i) => {\n      let sum = 0;\n      const from = Math.floor(i * size);\n      const to = Math.floor((i + 1) * size);\n      for (let k = from; k < to; k++) sum += channel[k] * channel[k];\n      return Math.sqrt(sum / Math.max(1, to - from));\n    });\n    const top = Math.max(...rms) || 1;\n    return rms.map((v) => Math.min(1, (v / top) ** 0.7));\n  } finally {\n    void ctx.close();\n  }\n}\n\nexport function VoiceNote({\n  src,\n  peaks,\n  duration: durationProp = 0,\n  transcript,\n  defaultTranscriptOpen = false,\n  speeds = [1, 1.5, 2],\n  unread = false,\n  label = \"Voice note\",\n  onPlay,\n  onEnded,\n  className = \"\",\n  ...props\n}: VoiceNoteProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const audioRef = React.useRef<HTMLAudioElement>(null);\n  const waveRef = React.useRef<HTMLDivElement>(null);\n  const wordsRef = React.useRef<HTMLParagraphElement>(null);\n  const [duration, setDuration] = React.useState(durationProp);\n  const [time, setTime] = React.useState(0);\n  const [playing, setPlaying] = React.useState(false);\n  const [rewinding, setRewinding] = React.useState(false);\n  const [heard, setHeard] = React.useState(!unread);\n  const [speed, setSpeed] = React.useState(0);\n  const [open, setOpen] = React.useState(defaultTranscriptOpen);\n  const [scrub, setScrub] = React.useState<number | null>(null);\n  const [bars, setBars] = React.useState(0);\n  const [decoded, setDecoded] = React.useState<number[] | null>(null);\n  const [grown, setGrown] = React.useState(false);\n  const [band, setBand] = React.useState({ x: 0, y: 0, w: 0, h: 0, on: false });\n  const resume = React.useRef(false);\n  const derived = React.useRef(\"\");\n\n  const source = peaks ?? decoded ?? [];\n  const heights = React.useMemo(() => resample(source, bars), [source, bars]);\n  const length = duration || durationProp || 1;\n  const shown = scrub !== null ? scrub * length : time;\n  const progress = Math.min(1, shown / length);\n  const filled = Math.floor(progress * bars + (playing || scrub !== null ? 0.5 : 0));\n  const spoken = transcript ? transcript.reduce((at, w, i) => (w.start <= shown + 0.05 ? i : at), -1) : -1;\n  const reading = playing || scrub !== null || (time > 0 && !rewinding);\n  const display = reading ? shown : length;\n\n  // As many bars as fit the width.\n  React.useLayoutEffect(() => {\n    const wave = waveRef.current;\n    if (!wave) return;\n    const measure = () => setBars(Math.max(12, Math.floor((wave.clientWidth + GAP) / (BAR + GAP))));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(wave);\n    return () => observer.disconnect();\n  }, []);\n\n  // No peaks given: read them off the audio itself.\n  React.useEffect(() => {\n    if (peaks) return;\n    const controller = new AbortController();\n    decodePeaks(src, 128, controller.signal)\n      .then((p) => !controller.signal.aborted && setDecoded(p))\n      .catch(() => {});\n    return () => controller.abort();\n  }, [src, peaks]);\n\n  // The bars grow in once there's something to show.\n  React.useEffect(() => {\n    if (!source.length || !bars || grown) return;\n    const frame = requestAnimationFrame(() => setGrown(true));\n    return () => cancelAnimationFrame(frame);\n  }, [source.length, bars, grown]);\n\n  const pause = React.useCallback(() => {\n    audioRef.current?.pause();\n    setPlaying(false);\n  }, []);\n\n  const play = async (from?: number) => {\n    const audio = audioRef.current;\n    if (!audio) return;\n    document.dispatchEvent(new CustomEvent(PLAY_EVENT, { detail: id }));\n    if (from !== undefined) {\n      audio.currentTime = from;\n      setTime(from);\n    }\n    audio.playbackRate = speeds[speed] ?? 1;\n    setRewinding(false);\n    setPlaying(true);\n    setHeard(true);\n    try {\n      await audio.play();\n      onPlay?.();\n    } catch {\n      setPlaying(false);\n    }\n  };\n\n  // Another voice note started: this one stops.\n  React.useEffect(() => {\n    const onOther = (e: Event) => {\n      if ((e as CustomEvent<string>).detail !== id) pause();\n    };\n    document.addEventListener(PLAY_EVENT, onOther);\n    return () => document.removeEventListener(PLAY_EVENT, onOther);\n  }, [id, pause]);\n\n  // While it plays, follow the audio, but only re-render when a bar, a second or a word changes.\n  React.useEffect(() => {\n    if (!playing) return;\n    let frame = 0;\n    const tick = () => {\n      const audio = audioRef.current;\n      if (audio) {\n        const t = audio.currentTime;\n        const word = transcript ? transcript.reduce((at, w, i) => (w.start <= t + 0.05 ? i : at), -1) : -1;\n        const key = `${Math.floor((t / length) * bars)}|${Math.floor(t)}|${word}`;\n        if (key !== derived.current) {\n          derived.current = key;\n          setTime(t);\n        }\n      }\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, length, bars, transcript]);\n\n  // At the end: hold full for a beat, then run the fill back to the start.\n  const finish = () => {\n    setPlaying(false);\n    setTime(length);\n    onEnded?.();\n    if (audioRef.current) audioRef.current.currentTime = 0;\n    setRewinding(true);\n    if (reduced) return setTime(0);\n    const begin = performance.now() + 500;\n    const step = (now: number) => {\n      const k = Math.min(1, Math.max(0, (now - begin) / 600));\n      setTime(length * (1 - (1 - (1 - k) ** 3)));\n      if (k < 1) requestAnimationFrame(step);\n    };\n    requestAnimationFrame(step);\n  };\n\n  const fraction = (clientX: number) => {\n    const r = waveRef.current?.getBoundingClientRect();\n    return r ? Math.min(1, Math.max(0, (clientX - r.left) / r.width)) : 0;\n  };\n  const seek = (t: number) => {\n    const next = Math.min(length, Math.max(0, t));\n    if (audioRef.current) audioRef.current.currentTime = next;\n    setRewinding(false);\n    setTime(next);\n  };\n\n  // The current word's highlight, measured where the word sits.\n  React.useLayoutEffect(() => {\n    const el = wordsRef.current?.querySelector<HTMLElement>(`[data-w=\"${spoken}\"]`);\n    setBand((b) => (el && open ? { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, on: true } : { ...b, on: false }));\n  }, [spoken, open]);\n\n  const lens = (i: number) => {\n    if (scrub === null || reduced) return 1;\n    const d = i - scrub * bars;\n    return 1 + 0.5 * Math.exp(-(d * d) / 8);\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={`@container w-full rounded-[20px] border border-border bg-card p-2 pr-2.5 text-card-foreground ${className}`}\n      {...props}\n    >\n      <audio\n        ref={audioRef}\n        src={src}\n        preload=\"metadata\"\n        onLoadedMetadata={(e) => Number.isFinite(e.currentTarget.duration) && setDuration(e.currentTarget.duration)}\n        onEnded={finish}\n        onPause={() => scrub === null && !audioRef.current?.ended && setPlaying(false)}\n      />\n\n      {/* The button and the speed line up with the waveform, the time sits under it. */}\n      <div className=\"flex items-start gap-2.5\">\n        <button\n          type=\"button\"\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          onClick={() => (playing ? pause() : play())}\n          className={`relative -mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform active:scale-95 ${FOCUS}`}\n        >\n          <span aria-hidden=\"true\" className=\"relative size-3.5\">\n            {[0, 1].map((half) => (\n              <span\n                key={half}\n                className=\"absolute inset-0 bg-current\"\n                style={{\n                  clipPath: (playing ? SHAPES.pause : SHAPES.play)[half],\n                  transition: reduced ? \"none\" : `clip-path 320ms ${EASE}`,\n                }}\n              />\n            ))}\n          </span>\n        </button>\n\n        <div className=\"min-w-0 flex-1\">\n          <div\n            ref={waveRef}\n            role=\"slider\"\n            tabIndex={0}\n            aria-label=\"Position\"\n            aria-valuemin={0}\n            aria-valuemax={Math.round(length)}\n            aria-valuenow={Math.round(shown)}\n            aria-valuetext={`${clock(shown)} of ${clock(length)}`}\n            onPointerDown={(e) => {\n              if (e.button !== 0) return;\n              e.currentTarget.setPointerCapture(e.pointerId);\n              resume.current = playing;\n              if (playing) audioRef.current?.pause();\n              setScrub(fraction(e.clientX));\n            }}\n            onPointerMove={(e) => scrub !== null && setScrub(fraction(e.clientX))}\n            onPointerUp={() => {\n              if (scrub === null) return;\n              const t = scrub * length;\n              setScrub(null);\n              if (resume.current) play(t);\n              else seek(t);\n            }}\n            onPointerCancel={() => {\n              setScrub(null);\n              if (resume.current) play();\n            }}\n            onKeyDown={(e) => {\n              const moves: Record<string, number> = { ArrowLeft: -2, ArrowDown: -2, ArrowRight: 2, ArrowUp: 2 };\n              if (e.key in moves) seek(shown + moves[e.key]);\n              else if (e.key === \"Home\") seek(0);\n              else if (e.key === \"End\") seek(length - 0.05);\n              else return;\n              e.preventDefault();\n            }}\n            className={`flex h-8 cursor-pointer touch-pan-y select-none items-center rounded-[6px] ${FOCUS}`}\n            style={{ gap: GAP }}\n          >\n            {heights.map((h, i) => (\n              <span\n                key={i}\n                aria-hidden=\"true\"\n                className={`shrink-0 rounded-full ${i < filled ? \"bg-foreground\" : \"bg-muted-foreground/35\"}`}\n                style={{\n                  width: BAR,\n                  height: grown ? `${Math.max(3 / 32, h) * 100}%` : \"3px\",\n                  transform: `scaleY(${lens(i)})`,\n                  transition: reduced\n                    ? \"none\"\n                    : `height 520ms ${EASE} ${grown && !playing ? Math.min(i * 7, 420) : 0}ms, transform 160ms ${EASE}, background-color 160ms`,\n                }}\n              />\n            ))}\n          </div>\n          <div className=\"mt-0.5 flex h-5 items-center gap-1.5 text-[11.5px] tabular-nums text-muted-foreground\">\n            <span aria-hidden=\"true\">\n              <NumberRoll value={Math.floor(Math.round(display) / 60)} duration={400} />:\n              <NumberRoll value={Math.round(display) % 60} format={{ minimumIntegerDigits: 2 }} duration={400} />\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"size-1.5 rounded-full bg-primary\"\n              style={{ transform: heard ? \"scale(0)\" : \"scale(1)\", opacity: heard ? 0 : 1, transition: reduced ? \"none\" : `transform 300ms ${EASE}, opacity 300ms` }}\n            />\n            {transcript && (\n              <button\n                type=\"button\"\n                aria-expanded={open}\n                aria-controls={`${id}-transcript`}\n                onClick={() => setOpen((o) => !o)}\n                className={`ml-auto inline-flex h-5 items-center gap-0.5 rounded-full px-1.5 text-[11.5px] text-muted-foreground transition-colors hover:text-foreground ${FOCUS}`}\n              >\n                Transcript\n                <ChevronDown\n                  aria-hidden=\"true\"\n                  className=\"size-3\"\n                  style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 300ms ${EASE}` }}\n                />\n              </button>\n            )}\n          </div>\n        </div>\n\n        <button\n          type=\"button\"\n          aria-label={`Playback speed, ${speeds[speed]}×`}\n          onClick={() => {\n            const next = (speed + 1) % speeds.length;\n            setSpeed(next);\n            if (audioRef.current) audioRef.current.playbackRate = speeds[next];\n          }}\n          className={`mt-1 inline-flex h-6 min-w-10 shrink-0 items-center justify-center rounded-full border border-border px-2 text-[11.5px] font-medium tabular-nums text-foreground transition-colors hover:bg-accent ${FOCUS}`}\n        >\n          <span aria-hidden=\"true\">\n            <NumberRoll value={speeds[speed]} format={{ maximumFractionDigits: 2 }} duration={450} />×\n          </span>\n        </button>\n      </div>\n\n      {transcript && (\n        <div\n          id={`${id}-transcript`}\n          className=\"grid\"\n          style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}` }}\n        >\n          <div className=\"overflow-hidden\" inert={!open || undefined}>\n            <p\n              ref={wordsRef}\n              className=\"relative mx-1 mb-1 mt-2 border-t border-border pt-2.5 text-[13px] leading-[1.6]\"\n              style={{ opacity: open ? 1 : 0, transition: reduced ? \"none\" : `opacity 300ms ${EASE}` }}\n            >\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute left-0 top-0 rounded-[5px] bg-primary/[0.12]\"\n                style={{\n                  transform: `translate(${band.x - 3}px, ${band.y}px)`,\n                  width: band.w + 6,\n                  height: band.h,\n                  opacity: band.on ? 1 : 0,\n                  transition: reduced ? \"none\" : `transform 260ms ${EASE}, width 260ms ${EASE}, opacity 200ms`,\n                }}\n              />\n              {transcript.map((w, i) => (\n                <React.Fragment key={i}>\n                  {i > 0 && \" \"}\n                  <span\n                    data-w={i}\n                    onClick={() => play(w.start)}\n                    className={`relative cursor-pointer rounded-[4px] transition-colors duration-200 hover:text-foreground ${\n                      spoken === -1 || i <= spoken ? \"text-foreground\" : \"text-muted-foreground\"\n                    }`}\n                  >\n                    {w.text}\n                  </span>\n                </React.Fragment>\n              ))}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/voice-note"}