Media

Waveform

An audio waveform drawn from peaks, with the played part filled, that seeks on click, drag or keys.

Built on
<svg>Pointer captureclip-pathrole="slider"
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.9 kB
Dependencies
React
Code
"use client";

import { Waveform } from "@/components/equip/waveform";

/* Stand-in loudness for a 52 second voice memo: speech-like bursts with pauses. */
const peaks = Array.from({ length: 48 }, (_, i) =>
  Math.abs(Math.sin(i * 0.9) * 0.55 + Math.sin(i * 0.31) * 0.35) * (i % 13 < 11 ? 1 : 0.15),
);

export default function Demo() {
  return <Waveform label="Voice memo" peaks={peaks} duration={52} defaultValue={19} style={{ width: 216 }} />;
}

Examples

Paired with an audio player

The peaks are measured from the same generated WAV the player plays, and both seek the one audio element.

Standup, Tuesday
Code
"use client";

import { useEffect, useRef, useState } from "react";
import { AudioPlayer } from "@/components/equip/audio-player";
import { Waveform } from "@/components/equip/waveform";

const RATE = 8000;
const SECONDS = 20;

/* Spoken-word stand-in: tone bursts with gaps, generated so nothing is fetched. */
function samples() {
  return Float32Array.from({ length: RATE * SECONDS }, (_, i) => {
    const t = i / RATE;
    const word = Math.max(0, Math.sin(t * 2.6) * Math.sin(t * 0.7 + 1));
    return Math.sin(2 * Math.PI * (160 + 40 * Math.sin(t * 3)) * t) * word * 0.6;
  });
}

function wav(data: Float32Array) {
  const view = new DataView(new ArrayBuffer(44 + data.length * 2));
  const word = (at: number, text: string) => [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
  word(0, "RIFF"); view.setUint32(4, 36 + data.length * 2, true); word(8, "WAVEfmt ");
  view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
  view.setUint32(24, RATE, true); view.setUint32(28, RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
  word(36, "data"); view.setUint32(40, data.length * 2, true);
  data.forEach((s, i) => view.setInt16(44 + i * 2, s * 16000, true));
  return new Blob([view.buffer], { type: "audio/wav" });
}

/** The loudest sample in each of count slices. */
function measure(data: Float32Array, count: number) {
  const size = Math.floor(data.length / count);
  return Array.from({ length: count }, (_, b) => {
    let max = 0;
    for (let i = b * size; i < (b + 1) * size; i++) max = Math.max(max, Math.abs(data[i] ?? 0));
    return max / 0.6;
  });
}

export default function Example() {
  const audio = useRef<HTMLAudioElement>(null);
  const [src, setSrc] = useState<string>();
  const [peaks, setPeaks] = useState<number[]>([]);
  const [time, setTime] = useState(0);
  useEffect(() => {
    const data = samples();
    setPeaks(measure(data, 120));
    const url = URL.createObjectURL(wav(data));
    setSrc(url);
    return () => URL.revokeObjectURL(url);
  }, []);
  return (
    <div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 420 }}>
      <Waveform
        label="Position in standup recording"
        peaks={peaks}
        bars={60}
        duration={SECONDS}
        value={time}
        onSeek={(s) => {
          if (audio.current) audio.current.currentTime = s;
          setTime(s);
        }}
      />
      <AudioPlayer
        ref={audio}
        variant="compact"
        src={src}
        title="Standup, Tuesday"
        onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}
      />
    </div>
  );
}

Long recording, still loading

bars resamples 600 peaks down to 72, keeping the loudest in each slice; buffered draws how far the file has arrived.

Board meeting, 12 March6:52 / 46:00
Code
"use client";

import { useState } from "react";
import { Waveform } from "@/components/equip/waveform";

const peaks = Array.from({ length: 600 }, (_, i) => {
  const phrase = Math.abs(Math.sin(i * 0.05));
  return phrase * (0.35 + 0.65 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.4)));
});

function clock(s: number) {
  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

export default function Example() {
  const [time, setTime] = useState(412);
  return (
    <div style={{ display: "grid", gap: 6, maxWidth: 480 }}>
      <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--eq-text-s)" }}>
        <strong>Board meeting, 12 March</strong>
        <span style={{ color: "var(--eq-ink-2)", fontVariantNumeric: "tabular-nums" }}>
          {clock(time)} / {clock(2760)}
        </span>
      </div>
      <Waveform label="Board meeting recording" peaks={peaks} bars={72} duration={2760} buffered={1400} value={time} onSeek={setTime} style={{ height: 36 }} />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
peaksnumber[]noneLoudness per slice, 0 to 1, oldest first. Measure them on the server or from decoded audio.
durationnumbernoneLength of the audio in seconds.
valuenumbernonePlayhead in seconds, when you control it. Feed it from the audio element's timeupdate.
defaultValuenumber0Playhead on first render, when you do not.
onSeek(seconds: number) => voidnoneCalled as the user clicks, drags or presses keys. Set the audio element's currentTime here.
labelstringnoneNames the slider for assistive tech.
bufferednumbernoneSeconds loaded so far, drawn a step darker than the unloaded bars.
barsnumbernoneHow many bars to draw. Peaks are resampled, keeping the loudest in each slice.
disabledbooleanfalseStops seeking and drops it from the tab order.
...restComponentProps<"div">noneEvery native div attribute. Set the height in style; it is 48px by default.

Anatomy

root.eq-waveformThe slider itself.
bars.eq-waveform-barsThe full waveform, unplayed.
loaded.eq-waveform-loadedThe same bars, clipped to the buffered length.
played.eq-waveform-playedThe same bars in the solid fill, clipped to the playhead.
head.eq-waveform-headThe playhead line.
hover.eq-waveform-hoverMouse only: where a click would land.
tip.eq-waveform-tipThe time under the mouse.

Keyboard

ArrowLeftArrowDownSeeks back 5 seconds.
ArrowRightArrowUpSeeks forward 5 seconds.
PageDownPageUpSeeks back or forward a tenth of the length.
HomeEndJumps to the start or the end.

Notes

  • It is a slider with aria-valuetext such as "1:05 of 3:20", so it is announced like a seek bar.
  • It does not play anything: pair it with an audio element or the audio player and wire value and onSeek both ways.
  • Drawing takes peaks rather than decoding audio, so a long file costs nothing to show; compute peaks once and store them.
  • Hover and the time tip are for mouse users; the keys and the announced value cover the same ground.
  • Bars are an svg stretched to the box, so any width and height work without measuring.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/waveform.tsx173 lines
"use client";

import { useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import "./waveform.css";

export type WaveformProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue" | "children"> & {
  /** Loudness per slice, 0 to 1, oldest first. Any length; bars resamples it. */
  peaks: number[];
  /** Length of the audio in seconds. */
  duration: number;
  /** Playhead in seconds, when you control it. */
  value?: number;
  /** Playhead on first render, when you do not. */
  defaultValue?: number;
  /** Called while the user clicks, drags or presses keys to move the playhead. */
  onSeek?: (seconds: number) => void;
  /** Names the control, e.g. "Voice note from Priya". */
  label: string;
  /** How far the audio has loaded, in seconds. Drawn a step darker than the rest. */
  buffered?: number;
  /** Number of bars to draw. Defaults to one per peak. */
  bars?: number;
  disabled?: boolean;
};

/** 65 gives "1:05". */
function clock(seconds: number): string {
  const total = Math.max(0, Math.floor(seconds));
  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}

/** Takes the loudest peak in each bucket, so short spikes survive the resampling. */
function resample(peaks: number[], count: number): number[] {
  if (count <= 0 || peaks.length === 0) return [];
  if (count === peaks.length) return peaks;
  return Array.from({ length: count }, (_, i) => {
    const from = Math.floor((i * peaks.length) / count);
    const to = Math.max(from + 1, Math.floor(((i + 1) * peaks.length) / count));
    return Math.max(...peaks.slice(from, to));
  });
}

function Bars({ peaks, className }: { peaks: number[]; className: string }) {
  return (
    <svg className={className} viewBox={`0 0 ${peaks.length} 2`} preserveAspectRatio="none" aria-hidden="true">
      {peaks.map((p, i) => {
        // Rounded, so a server and a browser whose Math.sin differ in the last digit draw the same markup.
        const h = Math.round(Math.max(0.06, Math.min(1, p || 0)) * 1000) / 1000;
        return <rect key={i} x={i + 0.18} y={Math.round((1 - h) * 1000) / 1000} width={0.64} height={Math.round(h * 2000) / 1000} />;
      })}
    </svg>
  );
}

export function Waveform({
  peaks,
  duration,
  value,
  defaultValue = 0,
  onSeek,
  label,
  buffered,
  bars,
  disabled = false,
  className,
  style,
  onKeyDown,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerLeave,
  ...rest
}: WaveformProps) {
  const [inner, setInner] = useState(defaultValue);
  const [dragging, setDragging] = useState(false);
  const [hover, setHover] = useState<number | null>(null);
  const length = Number.isFinite(duration) && duration > 0 ? duration : 0;
  const current = Math.min(Math.max(value ?? inner, 0), length);
  const shown = resample(peaks, bars ?? peaks.length);
  const at = length > 0 ? current / length : 0;
  const loaded = buffered === undefined || length === 0 ? 1 : Math.min(1, Math.max(0, buffered / length));

  function seek(seconds: number) {
    if (disabled || length === 0) return;
    const next = Math.min(Math.max(seconds, 0), length);
    if (value === undefined) setInner(next);
    onSeek?.(next);
  }

  function fraction(event: PointerEvent<HTMLDivElement>) {
    const box = event.currentTarget.getBoundingClientRect();
    return box.width > 0 ? Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)) : 0;
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || disabled) return;
    const big = length / 10;
    const moves: Record<string, number> = {
      ArrowLeft: current - 5,
      ArrowDown: current - 5,
      ArrowRight: current + 5,
      ArrowUp: current + 5,
      PageDown: current - big,
      PageUp: current + big,
      Home: 0,
      End: length,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    event.stopPropagation();
    seek(next);
  }

  return (
    <div
      role="slider"
      tabIndex={disabled ? -1 : 0}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={Math.round(length)}
      aria-valuenow={Math.round(current)}
      aria-valuetext={`${clock(current)} of ${clock(length)}`}
      aria-disabled={disabled || undefined}
      {...rest}
      className={["eq-waveform", className].filter(Boolean).join(" ")}
      style={
        {
          ...style,
          "--_at": at,
          "--_loaded": loaded,
          "--_hover": hover ?? 0,
        } as CSSProperties
      }
      data-dragging={dragging || undefined}
      data-hovering={hover !== null || undefined}
      onKeyDown={handleKeyDown}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        if (event.defaultPrevented || disabled || event.button !== 0) return;
        event.currentTarget.setPointerCapture(event.pointerId);
        event.currentTarget.focus({ preventScroll: true });
        setDragging(true);
        seek(fraction(event) * length);
      }}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        if (disabled) return;
        const f = fraction(event);
        if (event.pointerType === "mouse") setHover(f);
        if (dragging) seek(f * length);
      }}
      onPointerUp={(event) => {
        onPointerUp?.(event);
        setDragging(false);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        setHover(null);
      }}
      onLostPointerCapture={() => setDragging(false)}
    >
      <Bars peaks={shown} className="eq-waveform-bars" />
      <Bars peaks={shown} className="eq-waveform-loaded" />
      <Bars peaks={shown} className="eq-waveform-played" />
      <span className="eq-waveform-hover" aria-hidden="true">
        <span className="eq-waveform-tip">{clock((hover ?? 0) * length)}</span>
      </span>
      <span className="eq-waveform-head" aria-hidden="true" />
    </div>
  );
}
components/equip/waveform.css91 lines
@layer equip {
  .eq-waveform {
    position: relative;
    box-sizing: border-box;
    display: grid;
    height: 48px;
    min-width: 0;
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    cursor: pointer;
    touch-action: pan-y;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-waveform:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-waveform[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* Three copies of the bars, stacked: the groove, how far it has loaded, and what has played. */
  .eq-waveform-bars,
  .eq-waveform-loaded,
  .eq-waveform-played {
    grid-area: 1 / 1;
    display: block;
    width: 100%;
    height: 100%;
  }

  .eq-waveform-bars {
    fill: var(--eq-tint-strong);
  }

  .eq-waveform-loaded {
    fill: color-mix(in srgb, var(--eq-ink) 28%, transparent);
    clip-path: inset(0 calc((1 - var(--_loaded)) * 100%) 0 0);
  }

  /* Follows real playback, so it is not eased: easing would only make it lag. */
  .eq-waveform-played {
    fill: var(--eq-solid);
    clip-path: inset(0 calc((1 - var(--_at)) * 100%) 0 0);
  }

  .eq-waveform-head,
  .eq-waveform-hover {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 2px;
    margin-left: -1px;
    border-radius: 1px;
    pointer-events: none;
  }

  .eq-waveform-head {
    left: calc(var(--_at) * 100%);
    background: var(--eq-solid);
  }

  .eq-waveform-hover {
    left: calc(var(--_hover) * 100%);
    background: var(--eq-ink-3);
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-waveform[data-hovering] .eq-waveform-hover {
    opacity: 1;
  }

  .eq-waveform-tip {
    position: absolute;
    bottom: calc(100% + 4px);
    left: 50%;
    translate: -50% 0;
    padding: 3px 5px;
    border-radius: var(--eq-radius-s);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index