Media

Audio Waveform

A canvas waveform with a click/drag/keyboard scrubber — feed it precomputed peaks or let it decode a src, and it reports seeks while your own audio element plays.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** A highlighted span of the timeline — chorus, speaker turn, detected keyword… */
export interface AudioWaveformRegion {
  /** Start in seconds. */
  start: number
  /** End in seconds. Entries with `end <= start` are ignored. */
  end: number
  /** Optional caption pinned to the top-left of the band. */
  label?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/audio-waveform.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AudioWaveform" component: a canvas
amplitude waveform that doubles as a seek control. It never plays audio — the
consumer owns the `<audio>` element (or any clock), pushes `currentTime` in,
and applies the times the component reports through `onSeek`. That split is
the point: one waveform can drive a chat bubble, a full player, or a
transcript editor without ever owning playback.

Contract
- forwardRef div extending HTMLAttributes<HTMLDivElement>; the ref lands on
  the outer container. Merge className with cn().
- Data source is a discriminated union, so TypeScript rejects the half-filled
  cases at compile time:
    { peaks: number[]; duration: number; src?: string }   // precomputed
  | { src: string; peaks?: undefined; duration?: number } // decode on the fly
  `peaks` wins when both are present: hand the same src to your `<audio>` and
  the component still skips the download + decode entirely.
- Props: currentTime (number, required, seconds), onSeek?(time: number),
  regions? ({ start, end, label? }[]), height? (64), barWidth? (3),
  barGap? (2), resolution? (512 buckets when decoding), keyboardStep? (5s),
  disabled? (false), label? ("Audio position", the slider's accessible name),
  onDecoded?({ peaks, duration }) — fired once after a successful decode so
  the app can cache the peaks and never decode that clip again.
- Clamp every numeric prop (height >= 24, barWidth >= 1, barGap >= 0,
  keyboardStep > 0, resolution 16..4096, non-finite falls back to the default).
  barWidth = 0 or keyboardStep = 0 would otherwise produce an empty draw loop
  or a seek that never moves.

Behavior
- Decode path (src, no peaks): fetch(src, { signal }) -> arrayBuffer ->
  new AudioContext().decodeAudioData -> peak-per-bucket -> ready.
  * One AbortController per src. Re-check `signal.aborted` after every await;
    a late resolve must not setState on an unmounted component.
  * The AudioContext is closed in a finally block AND in the effect cleanup —
    browsers cap concurrent contexts (~6 in Chrome), so a leaked one per clip
    breaks the sixth waveform on the page.
  * Kick the async work off inside queueMicrotask so the effect body itself
    performs no synchronous setState (react-hooks/set-state-in-effect) and
    StrictMode's mount->cleanup->mount only sends one request.
  * A changed src resets peaks/error during render (prevSrc adjust-state), not
    in an effect — otherwise the previous clip's wave stays on screen while
    the new one downloads.
  * Peak extraction walks the first two channels only and strides through long
    buckets (cap ~512 samples per bucket): a 3-minute stereo clip is ~16M
    samples, and reading all of them blocks the main thread for tens of ms to
    move a bar by less than a pixel.
- Display peaks are normalised against their own maximum, so server-side
  arrays in any scale (0..1, 0..255, dB-ish) and quiet clips all render
  legibly; non-finite entries count as silence.
- The peaks array is resampled to however many bars fit (max per bar), so the
  same 512-bucket array works at 320px and at 1200px with no re-decode.
- Canvas painting:
  * Backing store = CSS size x pixel ratio, where the ratio is
    max(devicePixelContentBoxSize / cssWidth, devicePixelRatio), clamped to
    1..3 and to 4096 device px. Both measurements lie in opposite directions —
    device-pixel-content-box reports CSS px under an emulated device scale
    factor, devicePixelRatio reports 1 inside a window whose scale factor was
    overridden on a real 2x screen — and undersizing is exactly what "blurry
    on Retina" is.
  * ResizeObserver observes the canvas with { box: "device-pixel-content-box" }
    inside a try/catch: unsupported engines throw a WebIDL TypeError rather
    than ignoring the option, so the catch re-observes with default options.
  * Snap every x/y/width/height to the device-pixel grid; a bar landing on a
    half device pixel is drawn as two half-lit columns.
  * Two passes: whole wave at ~0.34 alpha, then the same path re-filled at
    alpha 1 inside a clip rect ending at the playhead. The bar straddling the
    playhead is split mid-bar instead of flipping a whole bar early or late.
  * Ink is `getComputedStyle(canvas).color`, i.e. currentColor, handed to
    fillStyle verbatim — no color parsing, and retinting is one className. A
    MutationObserver on documentElement (class/style/data-theme) bumps a tick
    so a dark-mode flip repaints instead of keeping the old ink.
- Interaction lives on a real DOM element, never on the canvas: an
  `absolute inset-0` div with role="slider", aria-valuemin/max/now and
  aria-valuetext ("0:38 of 1:36"). The canvas is aria-hidden.
  * Pointer: pointerdown seeks and captures the pointer; pointermove while
    captured keeps seeking (clamped to 0..duration); pointerup/cancel release.
  * Keyboard: Left/Down -keyboardStep, Right/Up +keyboardStep, PageUp/PageDown
    +/-max(step, duration/10), Home 0, End duration; preventDefault only on
    keys actually handled.
  * Hover (not while dragging) draws a 1px cursor plus a timestamp chip that
    left-aligns below 8% and right-aligns above 92% so it never hangs off the
    ends.
  * disabled keeps the slider in the tree (screen readers still get the
    position) but sets aria-disabled, tabIndex -1 and ignores all input.
- States: loading (animated comb skeleton + "Decoding…"), error, ready. Error
  text is translated for humans, not copied from the console: a fetch
  TypeError (offline / DNS / CORS) becomes "Couldn't load audio", an
  EncodingError becomes "Couldn't decode audio", a bad status keeps
  "Request failed (404)". Times are formatted mm:ss / h:mm:ss with an
  explicit "en-US" Intl.NumberFormat — never Intl.*(undefined).

Rendering & styling
- Semantic tokens only: the container carries text-primary and everything
  inked follows currentColor (canvas bars, playhead `bg-current`, region bands
  via color-mix(in oklab, currentColor 12%/30%, transparent)). Chrome uses
  bg-popover/text-popover-foreground (hover chip), bg-background/90 +
  text-foreground (region label), text-muted-foreground (times, skeleton),
  text-destructive (errors), focus-visible ring tokens. No hex/rgb/oklch
  literals anywhere.
- The loading skeleton is one node with a repeating-linear-gradient comb, not
  one span per bar — a 1200px waveform would otherwise mount ~240 throwaway
  elements per clip. It carries animate-pulse with
  motion-reduce:animate-none; nothing else animates, so reduced motion costs
  no functionality.
- Region bands render as DOM overlays (pointer-events-none) rather than canvas
  fills, so their labels stay real, selectable, truncatable text.

Customization levers
- Tint: pass className="text-destructive" (or any text token) — bars, playhead
  and region bands all follow currentColor. Change IDLE_ALPHA to widen or
  narrow the played/unplayed contrast.
- Density: barWidth/barGap/height turn the same data into a chat-bubble strip
  (height 32, barWidth 2, barGap 1) or a full editor lane (height 120).
  resolution trades decode detail for work.
- Mirror-mode: draw each bar from the top instead of centred, or draw min/max
  envelopes instead of peaks, by changing only the y/height math in tracePath.
- Drop the footer time row for embedded use, or add total-duration-only /
  remaining-time variants — it reads clampedTime and displayDuration.
- Regions: swap the band overlay for coloured `var(--chart-N)` fills when the
  host theme has a real palette, or make them clickable by removing
  pointer-events-none and rendering a button per region above the slider.
- Normalisation: remove the max-normalisation pass if you need absolute
  loudness comparable across clips.

Concepts

  • Peaks over pixels — the amplitude array is the contract, not the drawing. Precomputing it server-side removes the download and the decode from the client entirely; onDecoded exists so a one-time browser decode can be cached into that same shape.
  • Controlled playhead — the component owns no clock. currentTime in, onSeek out; if the consumer ignores onSeek, the playhead honestly refuses to move rather than faking a scrub.
  • Canvas draws, DOM interacts — hundreds of bars would be hundreds of nodes, so painting goes to canvas (aria-hidden) while a single role="slider" element owns focus, ARIA value text, pointer capture and the keyboard map.
  • Device-pixel snapping — the backing store is sized from the larger of devicePixelContentBoxSize and devicePixelRatio (each under-reports on a different setup) and every bar edge is rounded onto the device grid; measured on a 2x screen this cut half-lit "blur" pixels along a scanline from 45.8% to 1.9%.
  • Clip-split progress — one path, two fills: the played portion is the same geometry re-filled inside a clip rect that ends exactly at the playhead, so the boundary lands mid-bar instead of quantising to whole bars.
  • Cancellable decode — one AbortController per src, an aborted check after every await, and AudioContext.close() in both the finally and the effect cleanup; browsers cap concurrent contexts, so a leak breaks later clips on the same page rather than just wasting memory.

On This Page