Super-AI-Components

Waveform Editor

Sample-level audio editing

Zoom
131,072 samples visible · 1,365 samples per column
Playhead 54,000 smp · 0:01.224LeftRight nudge the focused handle by one sample
smp

0:01.224

smp

0:01.553

Length
14,500 smp
Duration
328.8 ms
Region
Breath
Region 54,000 to 68,500 samples, 0:01.224 to 0:01.553, 14,500 samples longShowing 131,072 samples from 0

What it is

A sample-accurate audio editing surface: a peak strip you can zoom until one column is one sample, a playhead you can scrub, a region you can select and adjust one sample at a time, and a set of actions that operate on that region. Every offset it takes and every offset it emits is an integer sample index — the sample rate is used only to also show those offsets as a time. It is fully controlled and owns no playback: it reports where the user put the playhead and holds no timer of its own.

Why it matters

This is a restoration, not a new idea. It was in the approved spec, collapsed into H3 track-lane during consolidation, and brought back by decisions.md D12 because that collapse was a mistake. gaps.md R3 states the reason precisely: track-lane selects whole clips, so region selection has no equivalent there, and H2 time-ruler tops out at frames rather than samples. Audio work has a resolution video tooling never needs — a click, a breath and a plosive are all shorter than a video frame — and a timeline that cannot address a single sample cannot fix any of them. That resolution is the whole justification for a separate component, which is why it is the one thing this API refuses to round away.

Anatomy

  1. 1waveform-editorRoot group. Carries data-sample-level, the programmatic form of the zoom-to-sample state.
  2. 2waveform-editor-toolbarZoom slider, Zoom to region, Fit, and the zoom stated in words.
  3. 3waveform-editor-zoomZoom slider, composed from Base UI so its thumb can be named. One stop halves or doubles the visible window.
  4. 4waveform-editor-view-readoutHow many samples are visible and how many samples each column covers — the zoom level as text.
  5. 5waveform-editor-canvasThe frame holding the peak strip, the region overlay and the playhead.
  6. 6waveform-editor-peaksThe drawn waveform. aria-hidden decoration: everything it shows is stated as text elsewhere.
  7. 7waveform-editor-region-overlayThe shaded selection band. Also decoration — the selection is carried by the boundary fields and the status region.
  8. 8waveform-editor-playheadThe playhead, as a real named slider. Dragging it and pressing an arrow key are the same gesture; the arrow key moves one sample.
  9. 9waveform-editor-playhead-readoutThe playhead offset in samples and in time. Deliberately not a live region.
  10. 10waveform-editor-region-bandBoth region boundaries as a two-thumb slider, named Region start and Region end.
  11. 11waveform-editor-region-clippedShown when the region runs past the visible window and its handles are parked at the edge.
  12. 12waveform-editor-region-fieldsThe boundaries as typeable sample offsets, plus the derived length and duration.
  13. 13waveform-editor-region-actionsActions on the selected region. Each names the region it will act on.
  14. 14waveform-editor-region-statusVisually hidden role=status announcing the current selection in samples and in time.
  15. 15waveform-editor-view-statusVisually hidden role=status announcing the visible window and whether it has reached sample level.

How to use it

Reach for it when the edit is smaller than a clip: trimming a breath, silencing a click, moving a cut a few samples off a transient. If the user is arranging whole clips on a timeline that is H3 track-lane, and if they are generating audio from a script that is E9 tts-composer — this component has no generate control on purpose. Drive it entirely from your own state: pass `peaks`, `sampleCount` and `sampleRate`, then hold `view`, `region` and `playhead` yourself and update them from `onViewChange`, `onRegionChange` and `onScrub`. Omitting a handler makes the matching control inert rather than fake: with no `onScrub` the playhead is disabled, and with no `onViewChange` the zoom controls are not rendered at all.

Do

  • Keep both region boundaries readable and typeable as sample offsets, so a one-sample adjustment never depends on how many pixels a column happens to be.

    Zoom
    65,536 samples visible · 683 samples per column
    Playhead 18,400 smp · 0:00.417LeftRight nudge the focused handle by one sample
    smp

    0:00.417

    smp

    0:00.580

    Length
    7,200 smp
    Duration
    163.3 ms
    Region
    Breath
    Region 18,400 to 25,600 samples, 0:00.417 to 0:00.580, 7,200 samples longShowing 65,536 samples from 0
  • Let the zoom run all the way down to one column per sample, and say so in words — a magnification factor does not tell anyone whether they can address a single sample yet.

    Zoom
    16 samples visible · 1 sample per column
    Playhead 18,400 smp · 0:00.417LeftRight nudge the focused handle by one sample
    smp

    0:00.417

    smp

    0:00.417

    Length
    6 smp
    Duration
    0.1 ms
    Region 18,396 to 18,402 samples, 0:00.417 to 0:00.417, 6 samples longShowing 16 samples from 18,392, one column per sample

Don't

  • Don't let the shaded band be the only representation of the selection — a waveform is a picture, and a selection that exists only in it cannot be read, checked or adjusted without seeing it.

  • Don't express boundaries in seconds. Two decimal places of a second is hundreds of samples wide, so every edit inside that window becomes unreachable through the very fields meant to make it precise.

    Start0.42 s
    End0.58 s

    Two decimal places of a second is 441 samples. Every edit inside that window is unreachable.

Accessibility

Keyboard
  • The tab-stop count is set by which handlers you pass, not by the audio. Fully wired with a region and four region actions it is twelve: the zoom slider, Zoom to region, Fit, the playhead, both region-band thumbs, the Start and End fields, and one per action. With no region it is three.
  • Omitting a handler does not always remove a stop. No `onScrub` disables the playhead thumb and takes it out of the tab order, and no `onViewChange` removes the zoom controls entirely — but no `onRegionChange` only makes the boundary fields `readOnly`, and a read-only input is still tabbable, so you tab through two fields you cannot change.
  • Every slider thumb is a visually hidden `<input type="range">`. Arrow keys move one sample; Shift with an arrow, PageUp and PageDown move `largeStep`, a tenth of the visible window; and Up and Down work as well as Left and Right — the on-screen Left/Right hint understates what is actually bound.
  • Home and End on the playhead go to the edges of the visible window, not of the buffer, because `min` and `max` are `view.start` and `view.end`. Arrowing cannot leave the window either: reaching audio that is off screen means zooming out or pressing Fit first.
  • On the two-thumb region band, Home and End address the neighbouring thumb rather than the buffer, so End on Region start collapses the region to a single sample instead of jumping to the end of the audio.
  • The boundary fields are `type="number"`: arrows step one sample, and an out-of-range value is clamped on the way out rather than blocked on the way in. Clearing a field emits nothing — the parsed value is `NaN`, the callback is skipped, and the controlled value snaps back on the next render.
  • Nothing answers to Delete, Backspace or Escape. There is no keyboard way to clear a selection or to fire a region action without tabbing to its button, and `action.disabled` is a real `disabled` attribute, so a disabled action is skipped rather than announced as unavailable.
Screen reader
  • The root is a `role="group"` named by `label`, which defaults to "Waveform editor". Two editors on one page both carrying the default announce identically — name them.
  • The peak strip and the shaded region overlay are `aria-hidden`, and nothing describes the waveform's shape in words. What survives without sight is offsets, lengths and durations, not contour — which is the deliberate trade, but it is a trade.
  • All four thumbs are named through `getAriaLabel` — Zoom level, Playhead, Region start, Region end — and each reports its own value text, so a boundary announces as "12,000 samples, 0:00.272" rather than a percentage. This is why the component reaches for the Base UI slider directly: the vendored `components/ui/slider` forwards neither prop and leaves an anonymous slider behind.
  • Selection and zoom each own a permanently mounted, visually hidden `role="status"`, so unlike most state changes in this registry these really are announced. The cost is that both fire during a drag: holding an arrow key on a boundary produces a stream of "Region 12,000 to 18,300 samples…" on top of the slider's own value text.
  • The playhead is deliberately outside that — no live region, reported only by its own value text when it has focus.
  • Each region action's `aria-label` replaces its visible text with "Trim region 12,000 to 18,300 samples", so an action always names what it will act on. The visible label is the prefix, so voice control still reaches it by name.
  • The unit is visible only. `UnitInput` renders "smp" as a sibling `<span>` that is in neither the field's name nor its description, so the fields announce as "Start, spin button, 12,000" with no unit at all. The `m:ss.mmm` hint beside them is properly wired through `aria-describedby`.
  • The "This region extends past the visible window" warning is a plain `<p>`, not a live region, so it appears silently the moment zooming crops the selection.
Focus
  • Clearing the selection unmounts both band thumbs, both boundary fields and Zoom to region at once, so focus falls to `<body>` and the next Tab restarts from the top of the page. That includes the case where a destructive region action is what cleared it — the button that was just pressed is one of the things that disappears.
  • Zooming never moves focus. A boundary that scrolls out of the window keeps its thumb, parked at the window edge, so a focused thumb is not lost — but it stops tracking the value it reports until you zoom back out.
  • The four thumbs carry `focus-visible:ring-3` and it never fires: Base UI focuses an `input` clipped to nothing inside each thumb, so the wrapping span never matches `:focus-visible`, and what a keyboard user sees is the span's permanent `shadow-sm` rather than a ring. Four of this component's twelve tab stops are therefore invisible. `UnitInput` does ring its whole wrapper on `focus-within`, and the zoom, Fit and region-action buttons inherit the shared `Button` ring, so the rest of the component is fine. Recorded in CONTINUE.md §8 — F5 `compare-viewer`, H2 `time-ruler` and H7 `stem-mixer` have the identical shape, and the repair is one decision across all four.

Watch out for

  • Letting the component own playback. It holds no timer and never advances the playhead: onScrub reports where the user put it, and moving it during playback is the caller's job. Wiring an interval into it is how two competing sources of truth for the playhead appear.
  • Announcing the playhead in a live region. It changes on every arrow press, so an aria-live on it talks over the selection and zoom announcements that actually carry meaning. The playhead slider's own value text already reports it on focus.
  • Pushing the far boundary when the near one moves. When a region runs past the visible window its off-screen handle is parked at the window edge for display only — writing that clamped value back silently rewrites a boundary the user never touched. Only the handle that moved is ever written.
  • Adding a regenerate or voice control because the content happens to be speech. That is E9 tts-composer's loop, and duplicating it here reproduces exactly the kind of overlap D12 restored this component to undo.

Installation

npx shadcn@latest add https://super-ai-components.vercel.app/r/waveform-editor.json

Requires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.