# Waveform

> Draws audio: a seekable waveform with regions and markers, a live waveform that scrolls with the signal, and a bar visualizer.

```tsx
import { Waveform } from "@/components/audio-ui/waveform";

// Stand-in for the peaks of a decoded audio file.
const VALUES = Array.from({ length: 512 }, (_, index) => {
  const time = index / 512;
  const section =
    0.5 + 0.28 * Math.sin(time * 11 + 0.8) + 0.18 * Math.sin(time * 29);
  const beat = 0.7 + 0.3 * Math.abs(Math.cos(index * 0.26));
  const grain = Math.abs((Math.sin(index * 12.9898) * 43_758.5453) % 1);
  const level = section * beat * (0.45 + 0.55 * grain) + 0.04;
  return Math.round(Math.min(1, level) * 1000) / 1000;
});

export function WaveformDemo() {
  return (
    <Waveform aria-label="Track progress" defaultValue={0.3} values={VALUES} />
  );
}
```

Components for drawing audio. `Waveform` shows a whole file and lets you scrub through it. `LiveWaveform` follows a signal as it plays. `BarVisualizer` turns that signal into a few bars you style in CSS. All three wrap the `Waveform` primitive from `@audio-ui/react`, which works with any audio: decoded PCM samples, a microphone or a stream from your server.

## Overview

- **Two kinds of data:** a file's shape is known in advance, so `Waveform` takes `values` (amplitudes from `0` to `1`, at any resolution). A live signal is not, so `LiveWaveform` and `BarVisualizer` take a [`source`](#live-signals) and follow it outside React.
- **Value:** `value` is the playback position, from `0` to `duration` (default `1`, a fraction; pass the track length in seconds to work in seconds). The primitive owns the seek position, not the audio: connect `onValueChange` to your own `<audio>` element.
- **Asymmetric shape:** give `values` the lowest and highest sample of each slice (`{ min, max }`) and the waveform is drawn asymmetric, as in a DAW. Plain amplitudes draw the same shape above and below the middle.
- **Part of the audio:** `range` shows only a stretch of the audio, such as a clip's window onto its source or a zoomed view. The playhead, seeking, hover, regions and markers follow that stretch. See [Showing part of the audio](#showing-part-of-the-audio).
- **Resolution:** the canvas draws as many bars as fit its width and keeps the loudest sample of each bar's slice, so detail holds at any size. Bars align to physical pixels, so their edges stay sharp at any pixel density or zoom. See [Feeding real audio](#feeding-real-audio).
- **Shapes:** `shape="mirror"` (the default) centers the bars, `"bars"` stands them on the baseline, and `"line"` joins the levels into one shape. A live waveform can also be a `"scope"`: raw samples traced as one line, like an oscilloscope.
- **On the timeline:** [`WaveformRegion`](#regions-and-markers) is a stretch you can move and resize, such as a loop. `WaveformMarker` is a labelled point. `WaveformHover` follows the pointer and shows the time under it.
- **Performance:** moving parts are painted outside React, and only when something changes. A waveform that is off screen or at rest uses no frames. When reduced motion is requested, live waveforms repaint a few times per second, without smoothing or idle animation.
- **Accessibility:** the seekable waveform holds a native `<input type="range">`: it takes focus, carries `aria-label`, announces the played share (or the text from `getAriaValueText`, such as a time), and supports the arrow keys, <kbd>Home</kbd>, <kbd>End</kbd>, <kbd>Page Up</kbd> and <kbd>Page Down</kbd>. Each region handle holds one too. Live waveforms are images: give them an `aria-label`.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/waveform
```

It installs the element alone. For the player above, ready to run: `npx shadcn@latest add @audio-ui/waveform-demo`.

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/waveform.tsx"
"use client";

import { Waveform as WaveformPrimitive } from "@audio-ui/react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";

/** Fades the canvas out at both ends, so bars don't stop on a hard edge. */
const EDGE_FADE =
  "[-webkit-mask-image:linear-gradient(to_right,transparent,black_16px,black_calc(100%-16px),transparent)] mask-[linear-gradient(to_right,transparent,black_16px,black_calc(100%-16px),transparent)]";

type LiveWaveformShape = NonNullable<WaveformPrimitive.Canvas.Props["variant"]>;
/** A file is drawn from its levels: the raw trace is for live samples. */
type WaveformShape = Exclude<LiveWaveformShape, "scope">;

type WaveformProps = Omit<WaveformPrimitive.Root.Props, "levels" | "peaks"> &
  Pick<
    WaveformPrimitive.Control.Props,
    "getAriaValueText" | "onSelectionChange" | "onSelectionCommitted"
  > & {
    /** How the amplitudes are drawn. */
    shape?: WaveformShape;
    /** The audio's shape, at any resolution: amplitudes (0-1), or `{ min, max }` samples (-1-1) per slice. */
    values: WaveformPrimitive.Root.Peaks;
    variant?: "default" | "playhead";
  };

function Waveform({
  "aria-describedby": ariaDescribedBy,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  children,
  className,
  getAriaValueText,
  onSelectionChange,
  onSelectionCommitted,
  shape,
  values,
  variant = "default",
  ...props
}: WaveformProps) {
  return (
    <WaveformPrimitive.Root
      className="group/waveform w-full min-w-0 data-disabled:cursor-not-allowed"
      peaks={values}
      {...props}
    >
      <WaveformPrimitive.Control
        aria-describedby={ariaDescribedBy}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledBy}
        className={cn(
          "relative h-20 rounded-lg relative w-full min-w-0 cursor-pointer touch-none select-none overflow-hidden py-1",
          "has-focus-visible:ring-3 has-focus-visible:ring-ring/50",
          "data-disabled:pointer-events-none data-disabled:opacity-50 data-loading:cursor-progress",
          className
        )}
        data-slot="waveform"
        getAriaValueText={getAriaValueText}
        onSelectionChange={onSelectionChange}
        onSelectionCommitted={onSelectionCommitted}
      >
        <WaveformPrimitive.Canvas
          className={cn("pointer-events-none [--waveform-bar-radius:max(0px,min(var(--radius-sm),3px))] [--waveform-color:color-mix(in_oklab,var(--muted-foreground)_50%,transparent)] [--waveform-progress-color:var(--primary)] block size-full", EDGE_FADE)}
          variant={shape}
        />
        {children}
        {variant === "playhead" ? (
          <>
            <WaveformPrimitive.Playhead className="inset-y-0 w-0.5 -translate-x-1/2 bg-primary group-data-outside/waveform:hidden" />
            <WaveformPrimitive.Thumb className="inset-y-0 my-auto size-3.5 rounded-sm border-2 border-background bg-primary shadow-md group-data-outside/waveform:hidden" />
          </>
        ) : null}
      </WaveformPrimitive.Control>
    </WaveformPrimitive.Root>
  );
}

function WaveformRegion({
  className,
  ...props
}: WaveformPrimitive.Region.Props) {
  return (
    <WaveformPrimitive.Region
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] bg-primary/15 ring-1 ring-primary/40 ring-inset pointer-events-none inset-y-0",
        className
      )}
      data-slot="waveform-region"
      {...props}
    />
  );
}

function WaveformRegionHandle({
  className,
  ...props
}: WaveformPrimitive.RegionHandle.Props) {
  return (
    <WaveformPrimitive.RegionHandle
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] bg-primary/60 has-focus-visible:bg-primary has-focus-visible:ring-3 has-focus-visible:ring-ring/50 pointer-events-auto absolute inset-y-0 w-1.5 cursor-ew-resize data-[edge=end]:right-0 data-[edge=start]:left-0",
        "after:absolute after:-inset-x-1.5 after:inset-y-0 pointer-coarse:after:-inset-x-3",
        className
      )}
      data-slot="waveform-region-handle"
      {...props}
    />
  );
}

function WaveformMarker({
  children,
  className,
  ...props
}: WaveformPrimitive.Marker.Props) {
  return (
    <WaveformPrimitive.Marker
      className={cn(
        "bg-foreground/50 pointer-events-none inset-y-0 w-px",
        className
      )}
      data-slot="waveform-marker"
      {...props}
    >
      {children ? (
        <span className="rounded-[min(var(--radius-sm),6px)] bg-muted px-1 font-medium text-[0.625rem] text-foreground absolute top-0 left-1 whitespace-nowrap">
          {children}
        </span>
      ) : null}
    </WaveformPrimitive.Marker>
  );
}

type WaveformHoverProps = Omit<WaveformPrimitive.Hover.Props, "children"> & {
  /** Prints the hovered position, e.g. as a time. Left out, only the line shows. */
  format?: (value: number) => string;
};

function WaveformHover({ className, format, ...props }: WaveformHoverProps) {
  const label = format
    ? (value: number) => (
        <span className="rounded-[min(var(--radius-sm),6px)] bg-foreground px-1.5 py-0.5 font-mono text-[0.625rem] text-background tabular-nums absolute top-0 left-1/2 -translate-x-1/2 whitespace-nowrap">
          {format(value)}
        </span>
      )
    : undefined;
  return (
    <WaveformPrimitive.Hover
      className={cn(
        "bg-foreground/40 pointer-events-none inset-y-0 z-10 w-px",
        className
      )}
      data-slot="waveform-hover"
      {...props}
    >
      {label}
    </WaveformPrimitive.Hover>
  );
}

type LiveWaveformProps = Omit<
  ComponentProps<"div">,
  "children" | "defaultValue"
> &
  Pick<
    WaveformPrimitive.Canvas.Props,
    "gain" | "idle" | "mirrored" | "mode" | "scrollSpeed"
  > & {
    /** Shows a sweep while the signal is on its way. */
    loading?: boolean;
    /** How the signal is drawn. `scope` traces raw samples instead of levels. */
    shape?: LiveWaveformShape;
    /** A live signal, such as `useAudioSignal(analyser).level` to scroll, `.bands` to dance or `.samples` for a scope. */
    source?: WaveformPrimitive.Root.Levels | null;
  };

function LiveWaveform({
  className,
  gain,
  idle,
  loading,
  mirrored,
  mode,
  scrollSpeed,
  shape,
  source,
  ...props
}: LiveWaveformProps) {
  return (
    <WaveformPrimitive.Root
      className={cn("h-16 relative w-full min-w-0", className)}
      data-slot="live-waveform"
      levels={source ?? undefined}
      loading={loading}
      role="img"
      {...props}
    >
      <WaveformPrimitive.Canvas
        className={cn("pointer-events-none [--waveform-bar-radius:max(0px,min(var(--radius-sm),3px))] [--waveform-color:var(--primary)] block size-full", EDGE_FADE)}
        gain={gain}
        idle={idle}
        mirrored={mirrored}
        mode={mode}
        scrollSpeed={scrollSpeed}
        variant={shape}
      />
    </WaveformPrimitive.Root>
  );
}

type BarVisualizerProps = Omit<
  ComponentProps<"div">,
  "children" | "defaultValue"
> &
  Pick<
    WaveformPrimitive.Bars.Props,
    "count" | "gain" | "idle" | "minLevel" | "mirrored"
  > & {
    /** Where the bars grow from. */
    align?: "center" | "end" | "start";
    /** Shows a sweep while the signal is on its way. */
    loading?: boolean;
    orientation?: "horizontal" | "vertical";
    /** A live signal, such as `useAudioSignal(analyser).bands`. */
    source?: WaveformPrimitive.Root.Levels | null;
  };

function BarVisualizer({
  align = "center",
  className,
  count,
  gain,
  idle,
  loading,
  minLevel,
  mirrored,
  orientation = "horizontal",
  source,
  ...props
}: BarVisualizerProps) {
  return (
    <WaveformPrimitive.Root
      className={cn(
        "text-primary data-[orientation=horizontal]:h-16 data-[orientation=vertical]:w-16 min-h-0 min-w-0 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full",
        className
      )}
      data-orientation={orientation}
      data-slot="bar-visualizer"
      levels={source ?? undefined}
      loading={loading}
      role="img"
      {...props}
    >
      <WaveformPrimitive.Bars
        className={cn(
          "gap-0.75 flex size-full justify-center",
          orientation === "vertical" && "flex-col",
          align === "start" && "items-start",
          align === "center" && "items-center",
          align === "end" && "items-end"
        )}
        count={count}
        gain={gain}
        idle={idle}
        minLevel={minLevel}
        mirrored={mirrored}
      >
        {(index) => (
          <WaveformPrimitive.Bar
            className={cn(
              "rounded-full bg-current min-h-0 min-w-0 flex-1",
              orientation === "vertical"
                ? "max-h-1.5 w-[calc(var(--waveform-level)*100%)]"
                : "h-[calc(var(--waveform-level)*100%)] max-w-1.5"
            )}
            index={index}
            key={index}
          />
        )}
      </WaveformPrimitive.Bars>
    </WaveformPrimitive.Root>
  );
}

export {
  BarVisualizer,
  LiveWaveform,
  Waveform,
  WaveformHover,
  WaveformMarker,
  WaveformRegion,
  WaveformRegionHandle,
};
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Waveform } from "@audio-ui/react";

<Waveform.Root>
  <Waveform.Control>
    <Waveform.Canvas />
    <Waveform.Playhead />
    <Waveform.Region>
      <Waveform.RegionHandle edge="start" />
      <Waveform.RegionHandle edge="end" />
    </Waveform.Region>
    <Waveform.Marker />
    <Waveform.Hover />
    <Waveform.Thumb />
  </Waveform.Control>
</Waveform.Root>
```

## Usage

```tsx
import {
  BarVisualizer,
  LiveWaveform,
  Waveform,
  WaveformHover,
  WaveformMarker,
  WaveformRegion,
  WaveformRegionHandle,
} from "@/components/audio-ui/waveform";
```

**A file, in seconds, with a playhead line and handle:**

```tsx
<Waveform
  aria-label="Track progress"
  duration={audio.duration}
  onValueChange={(seconds) => {
    audio.currentTime = seconds;
  }}
  value={currentTime}
  values={values}
  variant="playhead"
/>
```

**A microphone, scrolling as it comes in:**

```tsx
const { level } = useAudioSignal(analyser);

<LiveWaveform aria-label="Microphone input" mode="scrolling" source={level} />;
```

**A voice, as bars:**

```tsx
const { bands } = useAudioSignal(analyser);

<BarVisualizer aria-label="Agent speaking" mirrored source={bands} />;
```

## Feeding real audio

`values` is a plain prop: the component does not decode audio. `@audio-ui/core/analysis` and the `audio-decode` lib (`npx shadcn@latest add @audio-ui/audio-decode`) turn a real audio file into `values`: `decodeAudioChannels` (from `audio-decode`) reads every channel's PCM samples out of an `AudioContext`, `computePeaks` reduces them to each slice's lowest and highest sample across all channels (so a take panned hard to one side still draws), and `normalizePeaks` scales them so the loudest sample reaches full height.

```tsx
import { computePeaks, normalizePeaks } from "@audio-ui/core/analysis";
import { decodeAudioChannels } from "@/lib/audio-decode";

const context = new AudioContext();
const channels = await decodeAudioChannels(context, await file.arrayBuffer());

const values = normalizePeaks(computePeaks(channels, 4096));
```

The second argument to `computePeaks` is a resolution, not a bar count: the canvas fits whatever you pass to its own width and to any `range`. Compute it once per file, with more slices than the widest view you expect will show (a few thousand covers a zoomed-in clip editor). For a simpler shape, `downsampleWaveform` and `normalizeWaveform` give one amplitude per slice instead.

## Showing part of the audio

`range` is the stretch of the audio to show, `[start, end]` in `duration` units. Everything follows it: the bars are drawn from that stretch of `values` alone, a press seeks inside it, the input's keys stay inside it, and regions and markers are placed across it.

```tsx
<Waveform
  aria-label="Clip"
  duration={source.duration}
  range={[clip.offset, clip.offset + clip.length]}
  values={source.peaks}
/>
```

When the position is outside the stretch, the root carries `data-outside` and the playhead is hidden, rather than pinned to an edge it hasn't reached. Past the largest canvas a browser will draw (16,384 pixels a side), the bitmap is drawn coarser rather than going blank.

## Following playback

`value` re-renders React each time it changes, which is fine for a `timeupdate` event a few times a second. For a playhead that glides every animation frame, pass a `source`, an object in the shape of an external store:

```ts
interface WaveformSource {
  getSnapshot: () => number; // the position, in `duration` units
  subscribe: (listener: () => void) => () => void;
}
```

The waveform subscribes to it and moves the playhead, repaints the canvas and updates the input itself. While the pointer scrubs, the waveform follows the pointer and ignores the source until release; the same holds for a controlled `value` that updates late.

## Regions and markers

Anything inside `Waveform` is laid over the canvas, on the same timeline. A region is `[start, end]` in `duration` units; drag its body to move it, and give it a handle per edge to resize it:

```tsx
const [loop, setLoop] = useState<readonly [number, number]>([48, 96]);

<Waveform aria-label="Track progress" duration={180} values={values}>
  <WaveformRegion onValueChange={setLoop} value={loop}>
    <WaveformRegionHandle aria-label="Loop start" edge="start" />
    <WaveformRegionHandle aria-label="Loop end" edge="end" />
  </WaveformRegion>
  <WaveformMarker value={124}>Chorus</WaveformMarker>
  <WaveformHover format={formatTime} />
</Waveform>
```

A press on a region never seeks the waveform underneath. Each handle is a slider of its own: it takes focus, and the arrow keys, <kbd>Page Up</kbd> / <kbd>Page Down</kbd>, <kbd>Home</kbd> and <kbd>End</kbd> move its edge, which stops `minLength` short of the other one. Leave the handles out for a region that only moves, or pass `movable={false}` for one that only resizes.

## Live signals

A live waveform reads from a `source`, an object in the shape of an external store whose snapshot is the current levels, `0–1` each:

```ts
interface LevelsSource {
  getSnapshot: () => ArrayLike<number>;
  subscribe: (listener: () => void) => () => void;
}
```

The `use-audio-signal` hook (`npx shadcn@latest add @audio-ui/use-audio-signal`) reads two of them from an `AnalyserNode`, every animation frame and only while something is drawing them:

```tsx
const { level, bands, samples } = useAudioSignal(analyser, { bandCount: 32 });
```

- `level` is the loudest sample of the moment, as a single level. Draw it with `mode="scrolling"`: the waveform records it over time, the newest entering from the right, or from the middle outwards with `mirrored`. When the signal stops (pass `null` as the analyser to pause), the trace holds still.
- `bands` is the spectrum as log-spaced frequency bands, low to high. Draw it with a `BarVisualizer`, or a `LiveWaveform` in its default `mode="static"`: the bars stay in place and follow the bands, rising at once and falling smoothly.
- `samples` is the raw waveform of the moment, `-1` to `1`. Draw it with `shape="scope"`.

Any signal works. `createSource` from `@audio-ui/react` makes a source you push to yourself, from a WebSocket, a worklet or a test:

```tsx
import { createSource } from "@audio-ui/react";

const levels = createSource(new Float32Array(16));

socket.onmessage = (event) => levels.set(event.data);
```

While its signal is audible, a live waveform carries `data-active`. `idle` picks what the bars do meanwhile (`"pulse"` breathes, `"wave"` ripples), and `loading` sweeps across them while a connection is on its way.

## Theming

Colors come from CSS, and the canvas repaints when the theme changes:

**CSS variables**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `--waveform-color` |  | `text color` | Bars ahead of the position, and every bar of a live waveform. |
| `--waveform-progress-color` |  | `--waveform-color` | Bars behind the position. |
| `--waveform-bar-radius` |  | `0px` | Bar corner radius on the canvas (any CSS length). |
| `--waveform-position` |  |  | Set on the root: the playback position, as a percentage. |
| `--waveform-level` |  |  | Set on each bar of a `BarVisualizer`: its level, `0–1`. |

The bars of a `BarVisualizer` are plain elements: restyle them with `.cn-bar-visualizer-bar`. Those the playback position has passed carry `data-played`.

## API reference

### Root

Groups the parts of a waveform and owns what they show: the audio's shape (`peaks`, known upfront, or a live `levels` signal) and, for a seekable one, the playback position in `duration` units. The position comes from `value`, or from a live `source` that the waveform follows straight in the DOM without re-rendering React; it is exposed as the `--waveform-position` CSS variable (a percentage). While a live signal is audible the element carries `data-active`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `number` | `0` | Uncontrolled starting position. |
| `disabled` | `boolean` |  |  |
| `duration` | `number` | `1` | Length of the audio, in the unit `value` is in (typically seconds). |
| `largeStep` | `number` |  | Step for Page Up/Page Down and Shift + arrow keys. Defaults to a tenth of `duration`. |
| `levels` | `Levels` |  | A live signal drawn outside React, instead of `peaks`: a microphone, an analyser, a stream. |
| `loading` | `boolean` | `false` | Whether the audio is still on its way. Seeking waits, and the bars sweep. |
| `onValueChange` | `(value: number, eventDetails: ChangeEventDetails) => void` |  | Fires while seeking: press, drag, keyboard and input changes. |
| `onValueCommitted` | `(value: number, eventDetails: CommitEventDetails) => void` |  | Fires when a seek that moved the position ends. |
| `peaks` | `WaveformPeaks` |  | The audio's shape, at any resolution: a level per slice (0…1), or the lowest and highest sample of each (`{ min, max }`, -1…1), which draws the waveform lopsided where the audio is. The parts fit it to their size. |
| `range` | `readonly [number, number]` |  | The stretch of the audio to show, `[start, end]` in `duration` units: a clip's window onto its source, a zoomed view. The playhead, seeking, hover, regions and markers all follow it. Defaults to the whole audio. While the position is outside it, the root carries `data-outside`. |
| `source` | `Source` |  | A live position followed outside React; takes precedence over `value`. |
| `step` | `number` |  | Step for the arrow keys. Defaults to a hundredth of `duration`. |
| `value` | `number` |  | Playback position, from 0 to `duration`. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Region

A stretch of the timeline, such as a loop or a selection, laid over the waveform from `value[0]` to `value[1]`. Dragging its body moves it; add a `Waveform.RegionHandle` per edge to resize it. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Value` |  | Uncontrolled starting region. Defaults to the whole timeline. |
| `disabled` | `boolean` |  |  |
| `minLength` | `number` |  | The shortest the region can be resized to, in `duration` units. Defaults to the root's `step`. |
| `movable` | `boolean` | `true` | Whether dragging the region's body moves it. |
| `onValueChange` | `(value: Value, eventDetails: ChangeEventDetails) => void` |  | Fires while the region is moved or resized. |
| `onValueCommitted` | `(value: Value, eventDetails: CommitEventDetails) => void` |  | Fires when a move or a resize that changed the region ends. |
| `value` | `Value` |  | The region, as `[start, end]` in `duration` units. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving or resizing the region. |

### RegionHandle

Resizes one edge of the region: by dragging, or from the keyboard through the native `<input type="range">` it holds, which carries the accessible name (`aria-label` / `aria-labelledby`). Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"end" \| "start"` |  | The edge of the region this handle resizes. |
| `getAriaValueText` | `DescribeValue` |  | Returns how the position is announced, e.g. `"0:12 of 1:42"`. Defaults to the played share, e.g. `"30%"`. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving or resizing the region. |

### Control

The seekable surface: a press jumps to the pointer and a drag scrubs. It tracks the hovered position for `Waveform.Hover`, sets `touch-action: none` so touch scrubbing doesn't scroll the page, and holds the native `<input type="range">` that takes focus, handles the keyboard and carries the accessible name (`aria-label` / `aria-labelledby`). Leave it out for a waveform that is only shown. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaValueText` | `DescribeValue` |  | Returns how the position is announced, e.g. `"0:12 of 1:42"`. Defaults to the played share, e.g. `"30%"`. |
| `onSelectionChange` | `(range: readonly [number, number], eventDetails) => void` |  | Fires while a stretch is selected by dragging with Shift held, as `[start, end]` in `duration` units; feed it to a `Waveform.Region`. A plain press and drag still seek. `eventDetails.cancel()` keeps the selection as it was. |
| `onSelectionCommitted` | `(range: readonly [number, number], eventDetails) => void` |  | Fires when a selection that changed ends. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Bars

Draws the waveform as a fixed number of elements, one `Waveform.Bar` each, for a visualizer styled in plain CSS. Each bar's level, 0…1, is written to its `--waveform-level` CSS variable outside React: levels rise at once and fall smoothly, and nothing is written while they rest or sit off screen. Bars the playback position has passed carry `data-played`. Its children are a function called once per bar; left out, plain bars are rendered. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(index: number, count: number) => React.ReactNode` |  | Called once per bar; returns its `Waveform.Bar`. |
| `count` | `number` | `24` | How many bars to draw. The levels are fitted to them. |
| `gain` | `number` | `1` | Visual gain applied to the levels before they are drawn. |
| `idle` | `WaveformIdle` | `"none"` | What the bars do while there is no signal. |
| `minLevel` | `number` | `0.08` | Resting level, 0…1: silent bars keep this much of their size. |
| `mirrored` | `boolean` | `false` | Mirrors the levels around the middle bar, the first one in the center. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Bar

One bar of `Waveform.Bars`. Its level, 0…1, is its `--waveform-level` CSS variable: size it with `height: calc(var(--waveform-level) * 100%)`, or the width for a vertical layout. Renders a `<span>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` |  | The bar's place in the row, from 0. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Canvas

Draws the waveform on a canvas, laid out on the device pixel grid: as many bars as fit its width, every edge on a physical pixel, so it stays sharp at any pixel density and zoom level. It paints only when something changed, and not at all while it is off screen. With `peaks`, the played part is drawn in one color and the rest in another. With a live `levels` signal, `mode` picks between the current levels across the width and a scrolling record of the loudest one. The `scope` variant takes raw samples instead of levels, and traces them as they are. Colors come from CSS: `--waveform-color` and `--waveform-progress-color` (both default to the text color). The bar corner radius is `barRadius`, or the `--waveform-bar-radius` CSS length. Renders a `<canvas>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `barGap` | `number` | `1` | Space between bars, in CSS pixels, snapped to whole device pixels. |
| `barRadius` | `number` |  | Bar corner radius, in CSS pixels. Defaults to the `--waveform-bar-radius` CSS length. |
| `barWidth` | `number` | `2` | Bar width, in CSS pixels, snapped to whole device pixels. |
| `gain` | `number` | `1` | Visual gain applied to the levels before they are drawn. |
| `idle` | `WaveformIdle` | `"none"` | What the bars do while there is no signal, outside the scrolling mode. |
| `lineWidth` | `number` | `1.5` | Thickness of the `scope` trace, in CSS pixels. |
| `minBarHeight` | `number` | `2` | Shortest bar, in CSS pixels: silence still draws a sliver, so the timeline stays readable. |
| `mirrored` | `boolean` | `false` | Makes the drawing symmetric around the middle: the first level sits in the center, or, when scrolling, the newest one, with older ones moving out on both sides. |
| `mode` | `"scrolling" \| "static"` | `"static"` | How a live `levels` signal is drawn: `static` spreads the current levels across the width; `scrolling` records the loudest one over time, the newest entering from the right. |
| `scrollSpeed` | `number` | `60` | How fast the scrolling mode moves, in CSS pixels per second. |
| `variant` | `WaveformVariant` | `"mirror"` | `mirror` centers the bars on the middle line, `bars` stands them on the baseline, and `line` joins the levels into one continuous, centered shape. `scope` traces raw samples, -1…1, as a single line. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Thumb

A handle centered on the playback position. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Marker

A point of interest on the timeline, such as a cue or a chapter, positioned at `value`. Its children label it. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the marker sits, from 0 to `duration`. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Playhead

A marker at the playback position, with no interaction of its own. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Hover

Shown while the pointer is over the `Control`, positioned at the hovered point. Its children receive the hovered position, to print a time. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode \| ((value: number) => React.ReactNode)` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is scrubbing the waveform. |
| `data-loading` | Whether the audio is still on its way: there is nothing to seek yet. |

### Registry component

`aria-label`, `aria-labelledby`, and `aria-describedby` are forwarded to the element that needs them: the waveform's input, a region handle's input, or the live waveform itself.

#### Waveform

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `values` | `ArrayLike<number> \| { min, max }` |  | The audio's shape at any resolution: `0–1` amplitudes, or each slice's lowest and highest sample, `-1–1`. |
| `range` | `readonly [number, number]` | `whole audio` | The stretch of the audio to show, `[start, end]` in `duration` units: a clip's window onto its source, a zoomed view. The playhead, seeking, hover, regions and markers all follow it. Defaults to the whole audio. While the position is outside it, the root carries `data-outside`. |
| `value` | `number` |  | Controlled position, from `0` to `duration`. |
| `defaultValue` | `number` | `0` | Uncontrolled starting position. |
| `source` | `WaveformSource` |  | A live position followed outside React; takes precedence over `value`. |
| `duration` | `number` | `1` | Length of the audio, in the unit `value` is in (typically seconds). |
| `step` | `number` | `duration / 100` | Step for the arrow keys. Defaults to a hundredth of `duration`. |
| `largeStep` | `number` | `duration / 10` | Step for Page Up/Page Down and Shift + arrow keys. Defaults to a tenth of `duration`. |
| `disabled` | `boolean` | `false` | Disables pointer and keyboard interaction. |
| `loading` | `boolean` | `false` | Whether the audio is still on its way. Seeking waits, and the bars sweep. |
| `onValueChange` | `(value: number, eventDetails) => void` |  | Fires while seeking. `eventDetails.reason` is `"press"`, `"drag"`, `"keyboard"`, or `"input-change"`; call `eventDetails.cancel()` to reject it. |
| `onValueCommitted` | `(value: number, eventDetails) => void` |  | Fires when a seek that moved the position ends. |
| `getAriaValueText` | `(value: number, duration: number) => string` | `played share` | Returns how the position is announced, e.g. `"0:12 of 1:42"`. Defaults to the played share, e.g. `"30%"`. |
| `onSelectionChange` | `(range: readonly [number, number], eventDetails) => void` |  | Fires while a stretch is selected by dragging with Shift held, as `[start, end]` in `duration` units; feed it to a `Waveform.Region`. A plain press and drag still seek. `eventDetails.cancel()` keeps the selection as it was. |
| `onSelectionCommitted` | `(range: readonly [number, number], eventDetails) => void` |  | Fires when a selection that changed ends. |
| `shape` | `"mirror" \| "bars" \| "line"` | `"mirror"` | How the amplitudes are drawn. |
| `variant` | `"default" \| "playhead"` | `"default"` | `playhead` adds a playhead line and a handle. |
| `className` | `string` |  | Classes for the waveform surface. |

#### WaveformRegion

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `readonly [number, number]` |  | Controlled region, as `[start, end]` in `duration` units. |
| `defaultValue` | `readonly [number, number]` | `whole timeline` | Uncontrolled starting region. |
| `minLength` | `number` | `the waveform's step` | The shortest the region can be resized to, in `duration` units. Defaults to the root's `step`. |
| `movable` | `boolean` | `true` | Whether dragging the region's body moves it. |
| `disabled` | `boolean` | `false` | Also disabled when the waveform is. |
| `onValueChange` | `(value, eventDetails) => void` |  | Fires while moving or resizing. `eventDetails.edge` is `"start"`, `"end"` or `"both"`; `eventDetails.reason` is `"drag"`, `"keyboard"` or `"input-change"`. |
| `onValueCommitted` | `(value, eventDetails) => void` |  | Fires when a move or resize that changed the region ends. |

`WaveformRegionHandle` takes `edge` (`"start"` or `"end"`) and `getAriaValueText`. `WaveformMarker` takes `value` and shows its children as a label. `WaveformHover` takes `format`, which prints the hovered position; without it only the line shows.

#### LiveWaveform

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `LevelsSource \| null` |  | A live position followed outside React; takes precedence over `value`. |
| `mode` | `"static" \| "scrolling"` | `"static"` | `static` spreads the current levels across the width; `scrolling` records the loudest one over time. |
| `scrollSpeed` | `number` | `60` | How fast the scrolling mode moves, in CSS pixels per second. |
| `shape` | `"mirror" \| "bars" \| "line" \| "scope"` | `"mirror"` | How the signal is drawn. `scope` traces raw samples instead of levels. |
| `gain` | `number` | `1` | Visual gain applied to the levels before they are drawn. |
| `mirrored` | `boolean` | `false` | Makes the drawing symmetric: the first level in the center, or, when scrolling, the newest one, with older ones moving out on both sides. |
| `idle` | `"none" \| "pulse" \| "wave"` | `"none"` | In `static` mode, what the bars do while there is no signal. |
| `loading` | `boolean` | `false` | Whether the audio is still on its way. Seeking waits, and the bars sweep. |

#### BarVisualizer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `LevelsSource \| null` |  | A live position followed outside React; takes precedence over `value`. |
| `count` | `number` | `24` | How many bars to draw. The levels are fitted to them. |
| `mirrored` | `boolean` | `false` | Mirrors the levels around the middle bar, the lowest band in the center. |
| `minLevel` | `number` | `0.08` | Resting level, 0…1: silent bars keep this much of their size. |
| `gain` | `number` | `1` | Visual gain applied to the levels before they are drawn. |
| `idle` | `"none" \| "pulse" \| "wave"` | `"none"` | What the bars do while there is no signal. |
| `loading` | `boolean` | `false` | Whether the audio is still on its way. Seeking waits, and the bars sweep. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Where the bars grow from. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | `vertical` stacks the bars and grows them sideways. |

`@audio-ui/react/waveform` exposes the parts these components are built from:

**Parts**

| Name | Description |
| --- | --- |
| `Waveform.Root` | Owns the data (`peaks`, or a live `levels` source) and the position (`value`, `source`, `duration`). Carries `data-active` while a live signal is audible. |
| `Waveform.Control` | The seekable surface; holds the input. Leave it out for a waveform that is only shown. |
| `Waveform.Canvas` | Draws on a canvas: `variant`, `mode`, `scrollSpeed`, `barWidth`, `barGap`, `barRadius`, `minBarHeight`, `lineWidth`, `gain`, `mirrored`, `idle`. |
| `Waveform.Bars, Waveform.Bar` | Draws as elements: `count`, `minLevel`, `gain`, `mirrored`, `idle`. Each bar gets `--waveform-level`, `data-index` and `data-played`. |
| `Waveform.Region, Waveform.RegionHandle` | A stretch of the timeline, and the handle of one of its edges. |
| `Waveform.Marker` | A point on the timeline. |
| `Waveform.Playhead, Waveform.Thumb` | Follow the playback position. |
| `Waveform.Hover` | Shown at the hovered position; its children receive that position. |

Every part accepts a `render` prop and `className` / `style` as functions of state, and exposes that state as data attributes such as `data-disabled`, `data-dragging` and `data-loading`.

## Examples

### Default

```tsx
import { Waveform } from "@/components/audio-ui/waveform";

// Stand-in for the peaks of a decoded audio file.
const VALUES = Array.from({ length: 512 }, (_, index) => {
  const time = index / 512;
  const section =
    0.5 + 0.28 * Math.sin(time * 11 + 0.8) + 0.18 * Math.sin(time * 29);
  const beat = 0.7 + 0.3 * Math.abs(Math.cos(index * 0.26));
  const grain = Math.abs((Math.sin(index * 12.9898) * 43_758.5453) % 1);
  const level = section * beat * (0.45 + 0.55 * grain) + 0.04;
  return Math.round(Math.min(1, level) * 1000) / 1000;
});

export function WaveformDemo() {
  return (
    <Waveform aria-label="Track progress" defaultValue={0.3} values={VALUES} />
  );
}
```

### Region, markers and hover time

```tsx
"use client";

import { useState } from "react";
import {
  Waveform,
  WaveformHover,
  WaveformMarker,
  WaveformRegion,
  WaveformRegionHandle,
} from "@/components/audio-ui/waveform";

const DURATION_SECONDS = 180;

// Stand-in for the peaks of a decoded audio file.
const VALUES = Array.from({ length: 512 }, (_, index) => {
  const time = index / 512;
  const section =
    0.5 + 0.28 * Math.sin(time * 11 + 0.8) + 0.18 * Math.sin(time * 29);
  const beat = 0.7 + 0.3 * Math.abs(Math.cos(index * 0.26));
  const grain = Math.abs((Math.sin(index * 12.9898) * 43_758.5453) % 1);
  const level = section * beat * (0.45 + 0.55 * grain) + 0.04;
  return Math.round(Math.min(1, level) * 1000) / 1000;
});

function formatTime(seconds: number): string {
  const whole = Math.floor(seconds);
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
}

const describePosition = (value: number, duration: number) =>
  `${formatTime(value)} of ${formatTime(duration)}`;

export function WaveformRegionDemo() {
  const [loop, setLoop] = useState<readonly [number, number]>([48, 96]);

  return (
    <div className="flex w-full min-w-0 flex-col gap-2">
      <Waveform
        aria-label="Track progress"
        defaultValue={30}
        duration={DURATION_SECONDS}
        getAriaValueText={describePosition}
        values={VALUES}
        variant="playhead"
      >
        <WaveformRegion onValueChange={setLoop} value={loop}>
          <WaveformRegionHandle
            aria-label="Loop start"
            edge="start"
            getAriaValueText={formatTime}
          />
          <WaveformRegionHandle
            aria-label="Loop end"
            edge="end"
            getAriaValueText={formatTime}
          />
        </WaveformRegion>
        <WaveformMarker value={20}>Verse</WaveformMarker>
        <WaveformMarker value={124}>Chorus</WaveformMarker>
        <WaveformHover format={formatTime} />
      </Waveform>
      <p className="text-muted-foreground text-sm tabular-nums">
        Loop {formatTime(loop[0])} – {formatTime(loop[1])}
      </p>
    </div>
  );
}
```

### Live

The signal is recorded as it comes in, the newest level entering from the right.

```tsx
"use client";

import { createSource } from "@audio-ui/react";
import { useEffect, useState } from "react";
import { LiveWaveform } from "@/components/audio-ui/waveform";

/** A stand-in for a microphone, in the shape `useAudioSignal(analyser).level` has: speech-like bursts, so the demo moves without asking for the mic. */
function useSpeechLikeLevel() {
  const [source] = useState(() => createSource(new Float32Array(1)));

  useEffect(() => {
    const level = source.getSnapshot();
    let frameId: number;

    const tick = (now: number) => {
      const seconds = now / 1000;
      // Phrases a few seconds long, syllables inside them, and some grain.
      const phrase = Math.max(0, Math.sin(seconds * 1.1) + 0.35);
      const syllable = 0.55 + 0.45 * Math.sin(seconds * 9.5);
      const grain = 0.75 + 0.25 * Math.random();
      level[0] = Math.min(1, phrase * syllable * grain * 0.7);
      source.set(level);
      frameId = requestAnimationFrame(tick);
    };

    frameId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frameId);
  }, [source]);

  return source;
}

export function WaveformLiveDemo() {
  const level = useSpeechLikeLevel();
  return (
    <LiveWaveform
      aria-label="Microphone input"
      mode="scrolling"
      source={level}
    />
  );
}
```

### Player, wired to a real uploaded file

```tsx
"use client";

import {
  type AudioPeaks,
  computePeaks,
  normalizePeaks,
} from "@audio-ui/core/analysis";
import { createSource } from "@audio-ui/react";
import type { ChangeEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import { Toggle } from "@/components/ui/toggle";
import { Scrubber } from "@/components/audio-ui/scrubber";
import {
  Timeline,
  TimelineLane,
  TimelineMark,
  TimelineNavigator,
  TimelineRuler,
  TimelineViewport,
} from "@/components/audio-ui/timeline";
import {
  Waveform,
  WaveformHover,
  WaveformMarker,
  WaveformRegion,
  WaveformRegionHandle,
} from "@/components/audio-ui/waveform";
import { decodeAudioChannels } from "@/lib/audio-decode";

// More slices than the waveform has bars at any width or zoom; it keeps each bar's lowest and highest.
const PEAK_COUNT = 4096;
const EMPTY_PEAKS: number[] = [];
/** How many times closer than the whole track the view can zoom. */
const MAX_ZOOM_FACTOR = 16;
const ZOOM_STEP = 1.5;
/** How far in from the view's edge the playhead may go before the view turns the page, as a share of the view. */
const PAGE_MARGIN = 0.1;
/** The ruler's label spacings, in seconds: the first one that leaves room for the labels. */
const LABEL_STEPS = [1, 2, 5, 10, 15, 30, 60];
const MIN_LABEL_GAP_PX = 56;
const SAMPLE_SECONDS = 24;
const SAMPLE_RATE = 22_050;
const SAMPLE_NAME = "Sample loop";
/** A pentatonic phrase, in Hz, one note per half second. */
const SAMPLE_NOTES = [220, 261.6, 293.7, 329.6, 392, 329.6, 293.7, 261.6];
const MAX_MARKERS = 12;

/** A short synthesized phrase as a WAV file, so the player has something to show before a file is picked. */
function makeSampleWav(): Blob {
  const samples = SAMPLE_SECONDS * SAMPLE_RATE;
  const bytes = new ArrayBuffer(44 + samples * 2);
  const view = new DataView(bytes);
  const writeText = (offset: number, text: string) => {
    for (let index = 0; index < text.length; index += 1) {
      view.setUint8(offset + index, text.charCodeAt(index));
    }
  };
  writeText(0, "RIFF");
  view.setUint32(4, 36 + samples * 2, true);
  writeText(8, "WAVEfmt ");
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, SAMPLE_RATE, true);
  view.setUint32(28, SAMPLE_RATE * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeText(36, "data");
  view.setUint32(40, samples * 2, true);
  for (let index = 0; index < samples; index += 1) {
    const time = index / SAMPLE_RATE;
    const step = Math.floor(time * 2);
    const note = SAMPLE_NOTES[step % SAMPLE_NOTES.length] ?? 220;
    const sinceNote = time * 2 - step;
    // A swell over each four bars, so the shape has sections to loop and mark.
    const section = 0.35 + 0.65 * Math.abs(Math.sin(time / 4));
    const level = section * Math.exp(-sinceNote * 3) * 0.8;
    const sample = Math.sin(2 * Math.PI * note * time) * level;
    view.setInt16(44 + index * 2, Math.round(sample * 30_000), true);
  }
  return new Blob([bytes], { type: "audio/wav" });
}

/** Scrolls a zoomed view to the page that holds `x`, once the playhead nears or passes its edge. */
function followPlayhead(viewport: HTMLElement | null, x: number) {
  if (!viewport) {
    return;
  }
  const margin = viewport.clientWidth * PAGE_MARGIN;
  const { clientWidth, scrollLeft } = viewport;
  if (x < scrollLeft || x > scrollLeft + clientWidth - margin) {
    viewport.scrollLeft = Math.max(0, x - margin);
  }
}

const formatTime = (seconds: number) => {
  const whole = Math.max(0, Math.floor(seconds));
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};

interface LoopState {
  on: boolean;
  region: readonly [number, number];
}

interface ZoomState {
  /** Pixels per second. */
  px: number;
  zoomed: boolean;
}

/** The seconds the ruler labels: the smallest step that keeps labels apart at this zoom, in pixels per second. */
function rulerLabels(duration: number, zoom: number) {
  const step =
    LABEL_STEPS.find((candidate) => candidate * zoom >= MIN_LABEL_GAP_PX) ??
    LABEL_STEPS.at(-1) ??
    1;
  const count = duration > 0 ? Math.floor(duration / step) + 1 : 0;
  return Array.from({ length: count }, (_, index) => index * step);
}

/** One frame of playback: back to the loop's start past its end, and the view turned to follow a zoomed playhead. */
function playbackFrame(
  audio: HTMLAudioElement,
  loop: LoopState,
  zoom: ZoomState,
  viewport: HTMLDivElement | null
) {
  const [start, end] = loop.region;
  if (loop.on && end > start && audio.currentTime >= end) {
    audio.currentTime = start;
  }
  if (zoom.zoomed) {
    followPlayhead(viewport, audio.currentTime * zoom.px);
  }
}

/**
 * A player that shows what a seekable waveform does: a press or a drag seeks,
 * hovering names the time under the pointer, Shift + drag selects the loop, a
 * loop region has handles to drag and plays on repeat, markers sit on the timeline, and zooming shows a stretch
 * of the audio while the view follows the playhead. It starts on a synthesized
 * phrase and takes any audio file you pick; its shape is decoded once with
 * `decodeAudioChannels`, `computePeaks` and `normalizePeaks`.
 */
export function BlockWaveformPlayer() {
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const objectUrlRef = useRef<string | null>(null);
  const loopRef = useRef<LoopState>({ on: true, region: [0, 0] });
  const [position] = useState(() => createSource(0));
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
  const [fileName, setFileName] = useState(SAMPLE_NAME);
  const [peaks, setPeaks] = useState<AudioPeaks | null>(null);
  const [duration, setDuration] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [decoding, setDecoding] = useState(false);
  const [loopOn, setLoopOn] = useState(true);
  const [loop, setLoop] = useState<readonly [number, number]>([0, 0]);
  const [markers, setMarkers] = useState<number[]>([]);
  // Pixels per second; null while the whole track fits the width it is given.
  const [zoomPx, setZoomPx] = useState<number | null>(null);
  const [width, setWidth] = useState(0);
  const frameRef = useRef<HTMLDivElement | null>(null);
  const viewportRef = useRef<HTMLDivElement | null>(null);

  const fitPx = width > 0 && duration > 0 ? width / duration : 0;
  const zoom = Math.max(zoomPx ?? fitPx, fitPx);
  const zoomed = fitPx > 0 && zoom > fitPx * 1.001;
  const zoomRef = useRef<ZoomState>({ px: 0, zoomed: false });
  zoomRef.current = { px: zoom, zoomed };

  loopRef.current = { on: loopOn, region: loop };

  const load = useCallback(
    async (blob: Blob, name: string) => {
      if (objectUrlRef.current) {
        URL.revokeObjectURL(objectUrlRef.current);
      }
      const url = URL.createObjectURL(blob);
      objectUrlRef.current = url;

      setPlaying(false);
      position.set(0);
      setDuration(0);
      setPeaks(null);
      setMarkers([]);
      setZoomPx(null);
      setFileName(name);
      setAudioUrl(url);

      // Decoding a long file takes a moment: the waveform sweeps meanwhile.
      setDecoding(true);
      const context = new AudioContext();
      try {
        const channels = await decodeAudioChannels(
          context,
          await blob.arrayBuffer()
        );
        setPeaks(normalizePeaks(computePeaks(channels, PEAK_COUNT)));
      } finally {
        await context.close();
        setDecoding(false);
      }
    },
    [position]
  );

  useEffect(() => {
    const frame = frameRef.current;
    if (!frame) {
      return;
    }
    const observer = new ResizeObserver(([entry]) => {
      setWidth(entry?.contentRect.width ?? 0);
    });
    observer.observe(frame);
    return () => observer.disconnect();
  }, []);

  // Starts on the synthesized phrase.
  useEffect(() => {
    load(makeSampleWav(), SAMPLE_NAME);
    return () => {
      if (objectUrlRef.current) {
        URL.revokeObjectURL(objectUrlRef.current);
      }
    };
  }, [load]);

  const handleFileChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      const file = event.target.files?.[0];
      event.target.value = "";
      if (file) {
        load(file, file.name);
      }
    },
    [load]
  );

  const handleUploadClick = useCallback(() => {
    fileInputRef.current?.click();
  }, []);

  // The player has no visible media element: an Audio object per file plays it.
  useEffect(() => {
    if (!audioUrl) {
      return;
    }
    const audio = new Audio(audioUrl);
    audioRef.current = audio;
    return () => {
      audio.pause();
      audioRef.current = null;
    };
  }, [audioUrl]);

  // biome-ignore lint/correctness/useExhaustiveDependencies: audioUrl gates when audioRef.current first exists
  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) {
      return;
    }
    const controller = new AbortController();
    const { signal } = controller;
    const handleDurationChange = () => {
      if (!Number.isFinite(audio.duration)) {
        return;
      }
      setDuration(audio.duration);
      setLoop([audio.duration * 0.25, audio.duration * 0.55]);
    };
    const handleEnded = () => setPlaying(false);
    audio.addEventListener("loadedmetadata", handleDurationChange, { signal });
    audio.addEventListener("ended", handleEnded, { signal });
    if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
      handleDurationChange();
    }
    return () => controller.abort();
  }, [audioUrl]);

  // While it plays: follow the clock every frame, loop inside the region, and turn the page when the playhead leaves a zoomed view.
  useEffect(() => {
    const audio = audioRef.current;
    if (!(playing && audio)) {
      return;
    }
    let frame = 0;
    const tick = () => {
      playbackFrame(
        audio,
        loopRef.current,
        zoomRef.current,
        viewportRef.current
      );
      position.set(audio.currentTime);
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [playing, position]);

  const handlePlayingChange = useCallback((play: boolean) => {
    const audio = audioRef.current;
    if (!audio) {
      return;
    }
    if (play) {
      audio.play();
    } else {
      audio.pause();
    }
    setPlaying(play);
  }, []);

  const handleSeek = useCallback(
    (seconds: number) => {
      const audio = audioRef.current;
      if (audio) {
        audio.currentTime = seconds;
      }
      position.set(seconds);
    },
    [position]
  );

  const addMarker = useCallback(() => {
    const time = position.getSnapshot();
    setMarkers((current) =>
      current.length >= MAX_MARKERS || current.includes(time)
        ? current
        : [...current, time].sort((a, b) => a - b)
    );
  }, [position]);

  const zoomIn = useCallback(
    () => setZoomPx(Math.min(fitPx * MAX_ZOOM_FACTOR, zoom * ZOOM_STEP)),
    [fitPx, zoom]
  );
  const zoomOut = useCallback(
    () => setZoomPx(Math.max(fitPx, zoom / ZOOM_STEP)),
    [fitPx, zoom]
  );
  const handleZoomChange = useCallback((next: number) => setZoomPx(next), []);

  const labels = rulerLabels(duration, zoom);

  const ready = peaks !== null && duration > 0;

  return (
    <div className="rounded-xl flex w-full min-w-0 flex-col gap-4 border bg-card p-4">
      <input
        accept="audio/*"
        className="sr-only"
        onChange={handleFileChange}
        ref={fileInputRef}
        type="file"
      />
      <div ref={frameRef}>
        <Timeline
          disabled={!ready}
          duration={duration || 1}
          maxZoom={Math.max(fitPx * MAX_ZOOM_FACTOR, 1)}
          minZoom={fitPx || 0}
          onPositionChange={handleSeek}
          onZoomChange={handleZoomChange}
          source={position}
          zoom={fitPx > 0 ? zoom : undefined}
        >
          <TimelineViewport
            className="overflow-y-hidden rounded-lg border"
            ref={viewportRef}
            scrollbar="hidden"
          >
            <TimelineRuler aria-label="Position" getAriaValueText={formatTime}>
              {labels.map((seconds) => (
                <TimelineMark key={seconds} value={seconds}>
                  {formatTime(seconds)}
                </TimelineMark>
              ))}
            </TimelineRuler>
            <TimelineLane className="h-20">
              <Waveform
                aria-label="Track position"
                // The viewport is the frame: the waveform fills the lane without a box of its own.
                className="h-20 rounded-none"
                disabled={!ready}
                duration={duration || 1}
                getAriaValueText={formatTime}
                loading={decoding || !ready}
                onSelectionChange={setLoop}
                onValueChange={handleSeek}
                source={position}
                values={peaks ?? EMPTY_PEAKS}
                variant="playhead"
              >
                {ready ? (
                  <WaveformRegion onValueChange={setLoop} value={loop}>
                    <WaveformRegionHandle
                      aria-label="Loop start"
                      edge="start"
                      getAriaValueText={formatTime}
                    />
                    <WaveformRegionHandle
                      aria-label="Loop end"
                      edge="end"
                      getAriaValueText={formatTime}
                    />
                  </WaveformRegion>
                ) : null}
                {markers.map((time, index) => (
                  <WaveformMarker key={time} value={time}>
                    {index + 1}
                  </WaveformMarker>
                ))}
                <WaveformHover format={formatTime} />
              </Waveform>
            </TimelineLane>
          </TimelineViewport>
          <TimelineNavigator className="mt-2" getAriaValueText={formatTime} />
        </Timeline>
      </div>
      {ready ? (
        <Scrubber
          duration={duration}
          onPlayingChange={handlePlayingChange}
          onSeek={handleSeek}
          playing={playing}
          source={position}
        />
      ) : null}
      <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
        <Toggle
          aria-label="Loop the region"
          onPressedChange={setLoopOn}
          pressed={loopOn}
          size="sm"
          variant="outline"
        >
          <IconPlaceholder
            hugeicons="RepeatIcon"
            lucide="RepeatIcon"
            phosphor="RepeatIcon"
            remixicon="RiRepeatLine"
            tabler="IconRepeat"
          />
          Loop
        </Toggle>
        <Button
          disabled={!ready || markers.length >= MAX_MARKERS}
          onClick={addMarker}
          size="sm"
          type="button"
          variant="outline"
        >
          Add marker
        </Button>
        <div className="flex items-center gap-1">
          <Button
            aria-label="Zoom out"
            disabled={!(ready && zoomed)}
            onClick={zoomOut}
            size="icon-sm"
            type="button"
            variant="outline"
          >
            <IconPlaceholder
              hugeicons="ZoomOutAreaIcon"
              lucide="ZoomOutIcon"
              phosphor="MagnifyingGlassMinusIcon"
              remixicon="RiZoomOutLine"
              tabler="IconZoomOut"
            />
          </Button>
          <span className="min-w-8 text-center tabular-nums">
            {fitPx > 0 ? (zoom / fitPx).toFixed(zoomed ? 1 : 0) : 1}×
          </span>
          <Button
            aria-label="Zoom in"
            disabled={!ready || zoom >= fitPx * MAX_ZOOM_FACTOR * 0.999}
            onClick={zoomIn}
            size="icon-sm"
            type="button"
            variant="outline"
          >
            <IconPlaceholder
              hugeicons="ZoomInAreaIcon"
              lucide="ZoomInIcon"
              phosphor="MagnifyingGlassPlusIcon"
              remixicon="RiZoomInLine"
              tabler="IconZoomIn"
            />
          </Button>
        </div>
        <Button
          aria-label="Choose an audio file"
          className="ml-auto"
          onClick={handleUploadClick}
          size="icon-sm"
          type="button"
          variant="outline"
        >
          <IconPlaceholder
            hugeicons="Upload01Icon"
            lucide="UploadIcon"
            phosphor="UploadIcon"
            remixicon="RiUploadLine"
            tabler="IconUpload"
          />
        </Button>
        <span className="max-w-32 truncate">{fileName}</span>
      </div>
    </div>
  );
}
```
