daw-ui

Waveform

The shape of audio, in a region of a timeline or on its own, drawn on canvas once and moved by CSS.

Anatomy

import { createPeaks } from '@addstack/daw-ui';
import { Waveform } from '@addstack/daw-ui/react';

const peaks = createPeaks([buffer.getChannelData(0)], buffer.sampleRate);

<Waveform.Root peaks={peaks} read={() => player.currentTime} aria-label="Kick" className="h-8 w-64">
  <Waveform.Shape className="text-neutral-400" />
  <Waveform.Progress className="text-orange-500" />
</Waveform.Root>

A waveform needs nothing around it: in a sample browser or on a sampler, it shows its audio across its own width, from offset for duration (the whole file by default), and takes its playhead from position or read, in seconds of the audio. It is an image named by aria-label, in your application's language; size it with CSS.

In a timeline

In a Region on a timeline, a waveform shows the audio the region shows, from its offset for its duration, on the timeline's axis, and follows the region as it moves; the timeline gives it the playhead. Size it across the region's width. Outside a region, in a Timeline.Root, a waveform lies on the timeline itself, from its second 0, as a single take in an audio editor.

<Timeline.Root start={0} end={30} read={() => transport.time}>
  <div className="relative h-16">
    <Region.Root at={4} duration={8} offset={1.5}>
      <Waveform.Root peaks={peaks} aria-label={clip.name} className="h-full">
        <Waveform.Shape className="text-sky-700" />
        <Waveform.Progress className="text-sky-300" />
      </Waveform.Root>
    </Region.Root>
  </div>
  <Timeline.Playhead />
</Timeline.Root>
Zoom

Peaks

A waveform draws from peaks: the lowest and highest sample of every 256 samples, then of every 512, and so on, like the mipmaps of a texture. Drawing reads the level closest to the zoom, so its cost follows the pixels on screen, not the length of the audio.

import { createPeaks, peaksFromAudiowaveform } from '@addstack/daw-ui';

// From decoded audio: one pass, 34 ms for ten minutes of stereo at 48 kHz.
const peaks = createPeaks([buffer.getChannelData(0), buffer.getChannelData(1)], buffer.sampleRate);

// Or computed ahead of time by the audiowaveform tool, so a long file is never decoded in the browser.
const peaks = peaksFromAudiowaveform(await (await fetch('/take-1.json')).json());

For long files, call createPeaks in a worker: its data are typed arrays, which move between threads without copying. Values are 8 bits, so an hour of stereo takes under 6 MB. readPeaks gives the columns to your own drawing code, for WebGL or an overview.

Recording

createPeaksRecorder makes peaks that grow: append the blocks of samples as they arrive, e.g. from an AudioWorklet on the microphone. A waveform of them draws only the tile the audio arrives in, once per frame, however many blocks come in between. In a region, let the region read the take's duration; on its own, the waveform grows with the take.

import { createPeaksRecorder } from '@addstack/daw-ui';

const take = createPeaksRecorder({ sampleRate: context.sampleRate, channels: 1 });
worklet.port.onmessage = ({ data }) => take.append([data]); // a Float32Array per block

<Region.Root at={recordStart} read={() => ({ duration: take.duration })}>
  <Waveform.Root peaks={take} aria-label="Take 3" className="h-full" />
</Region.Root>

Zooming in to samples

Peaks keep one minimum and maximum per 256 samples. Zoomed in further than that, a waveform shows them as steps, unless you give it the samples, which you already hold in the AudioBuffer you play:

<Waveform.Root peaks={peaks} samples={[buffer.getChannelData(0), buffer.getChannelData(1)]} />

Then it draws from them below the peaks' finest level, down to single samples as a line.

How it stays fast

  • Tiles are drawn once. The waveform is drawn into canvas tiles of 1024 pixels, only those in view and one on each side. Tiles are placed in time with the timeline's CSS variables: playback and scrolling move them without drawing, and draw only tiles that come into view.
  • A zoom stretches, then sharpens. During a zoom the tiles stretch in CSS. When it rests, or goes past twice or half the scale they were drawn at, a sharp layer is drawn over them, and the stretched one goes when the new one covers the view.
  • Drawing never takes a frame. All waveforms share one queue that draws at most 4 ms per frame, visible tiles first. A waveform scrolled out of sight draws nothing.
  • The played part is CSS. Waveform.Progress is a second drawing, clipped at the playhead: each frame of playback rewrites its clip-path, and nothing else.

On 32 clips of four minutes under a ruler and a grid, with the CPU slowed down 4×, playback takes about 3 ms of main thread per frame and draws nothing; scrolling about 4.5 ms, drawing only tiles that come into view; recording a 33rd take draws one tile per frame at 3.5 ms; a continuous zoom takes about 9 ms. See performance.

Styling

The waveform is drawn in the CSS color of Waveform.Shape and Waveform.Progress: className="text-sky-700 dark:text-sky-400". It is drawn again when the color changes, by a theme, a media query or a hover. The root is a plain element for backgrounds and borders.

For the channels of a stereo file one above the other, render a shape per channel and place each with style:

<Waveform.Shape channel={0} style={{ bottom: '50%' }} />
<Waveform.Shape channel={1} style={{ top: '50%' }} />

API reference

Root

The audio, in a region or on its own. Renders a <div> with role="img", position: relative and overflow: hidden.

Prop

Type

Shape

The waveform, drawn in this element's color. Renders a <div> that fills the root, with the canvas tiles inside.

Prop

Type

Progress

The part before the playhead, drawn in this element's color over the shape. Renders a <div> like Shape, clipped in CSS.

Prop

Type

On this page