Waveform ASCII Scrub

Waveform

An audio-style waveform rendered as columns of ASCII density glyphs with a draggable playhead — dragging (or hovering, or holding keyboard focus) subdivides the glyph columns nearest the cursor into finer sub-columns that re-ink the region at higher resolution, while the rest of the strip stays coarse.

Install
npx shadcn add https://design.helpmarq.com/r/waveform-ascii-scrub.json
Source
registry/core/waveform-ascii-scrub/component.tsx
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// WaveformAsciiScrub — an audio-style waveform rendered as columns of ASCII
// density glyphs, with a draggable playhead. The mechanic that sets this
// apart from every neighboring instrument: resolution is spatial and local,
// not a global hover-brighten. At rest the waveform renders at a fixed
// coarse column count (one glyph column per base sample). Dragging the
// playhead (or just hovering, or holding keyboard focus) subdivides the few
// base columns nearest the pointer into several finer sub-columns, each
// independently re-sampling the underlying (locally synthesised, no audio
// API) amplitude function at its own narrower time span — so the region
// under the cursor is re-inked at genuinely finer detail (more, thinner
// glyph columns revealing sub-peaks the coarse column averaged away) while
// the rest of the strip stays exactly as coarse as it was. The radius eases
// in/out over ~250ms rather than snapping.
//
// Distinct from voice-recorder-meter (real Web Audio analyser bars, one bin
// per bar, no drag/scrub, no resolution concept at all), scrubber-film-strip
// (a physical claw-and-perforation playback scrubber — its mechanic is
// slow-drag-snaps/fast-drag-glides, not spatial resolution), sparkline-ascii
// (a DOM character grid, fixed resolution throughout, no drag), and
// histogram-live-grain (distribution of arrivals, not a scrub position).
// ---------------------------------------------------------------------------

export interface WaveformAsciiScrubProps {
  /** Controlled scrub position, 0..1. Omit for uncontrolled (see defaultValue). */
  value?: number;
  /** Initial position, 0..1, when uncontrolled. Default 0.3. */
  defaultValue?: number;
  /** Total duration in seconds, used only to format aria-valuetext. Default 180. */
  duration?: number;
  onValueChange?: (value: number) => void;
  label?: string;
  className?: string;
}

const BASE_COLS = 64;
const SUBDIVIDE = 3;
const FOCUS_RADIUS_COLS = 6;
const MAX_ROWS = 7;
const CHARSET = " .:-=+*#%@";
const VIEW_W = 600;
const VIEW_H = 150;
const EASE_EPS = 0.02;

// Deterministic synthetic amplitude — layered sine partials plus a slow
// swell envelope, so a coarse sample averages away detail that a finer
// sample genuinely reveals. No audio API, no randomness.
function amplitude(t: number): number {
  const s = t * 40;
  let v = 0;
  v += 0.5 * Math.sin(s * 2.13 + 1.7);
  v += 0.3 * Math.sin(s * 5.02 + 0.4);
  v += 0.15 * Math.sin(s * 11.7 + 2.9);
  v += 0.1 * Math.sin(s * 23.3 + 5.1);
  const swell = 0.55 + 0.45 * Math.pow(Math.sin(t * Math.PI * 2 * 1.3 + 0.6), 2);
  return Math.min(1, Math.abs(v) * swell);
}

function peakIn(t0: number, t1: number): number {
  const SAMPLES = 6;
  let peak = 0;
  for (let s = 0; s < SAMPLES; s++) {
    const t = t0 + ((t1 - t0) * (s + 0.5)) / SAMPLES;
    peak = Math.max(peak, amplitude(t));
  }
  return peak;
}

function formatTime(frac: number, duration: number): string {
  const secs = Math.round(Math.max(0, Math.min(1, frac)) * duration);
  const m = Math.floor(secs / 60);
  const s = secs % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

interface Tokens {
  fg: string;
  bg: string;
  muted: string;
  border: string;
  accent: string;
}

function readTokens(): Tokens {
  const cs = getComputedStyle(document.documentElement);
  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;
  return {
    fg: get("--foreground", "#171717"),
    bg: get("--background", "#ffffff"),
    muted: get("--muted", "#4d4d4d"),
    border: get("--border", "#ebebeb"),
    accent: get("--accent", "#006bff"),
  };
}

function useTokens(): Tokens {
  const [tokens, setTokens] = useState<Tokens>(() =>
    typeof document === "undefined"
      ? { fg: "#171717", bg: "#ffffff", muted: "#4d4d4d", border: "#ebebeb", accent: "#006bff" }
      : readTokens()
  );
  useEffect(() => {
    const sync = () => setTokens(readTokens());
    sync();
    const mo = new MutationObserver(sync);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    return () => mo.disconnect();
  }, []);
  return tokens;
}

export function WaveformAsciiScrub({
  value,
  defaultValue = 0.3,
  duration = 180,
  onValueChange,
  label = "Scrub position",
  className = "",
}: WaveformAsciiScrubProps) {
  const uid = useId();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const tokens = useTokens();

  const isControlled = value !== undefined;
  const [inner, setInner] = useState(defaultValue);
  const playhead = isControlled ? (value as number) : inner;

  const [dragging, setDragging] = useState(false);
  const [hoverFocus, setHoverFocus] = useState<number | null>(null);
  const [focused, setFocused] = useState(false);

  const reducedRef = useRef(false);
  useEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  }, []);

  function commit(next: number) {
    const clamped = Math.max(0, Math.min(1, next));
    if (!isControlled) setInner(clamped);
    onValueChange?.(clamped);
  }

  function posFromClientX(clientX: number): number {
    const rect = wrapRef.current?.getBoundingClientRect();
    if (!rect || rect.width === 0) return playhead;
    return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
  }

  function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
    commit(posFromClientX(e.clientX));
  }
  function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>) {
    const pos = posFromClientX(e.clientX);
    if (dragging) commit(pos);
    else setHoverFocus(pos);
  }
  function handlePointerUp() {
    setDragging(false);
  }
  function handlePointerLeave() {
    if (!dragging) setHoverFocus(null);
  }
  function handleKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
    let next = playhead;
    if (e.key === "ArrowLeft") next -= 0.01;
    else if (e.key === "ArrowRight") next += 0.01;
    else if (e.key === "PageDown") next -= 0.05;
    else if (e.key === "PageUp") next += 0.05;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = 1;
    else return;
    e.preventDefault();
    commit(next);
  }

  // priority: an active drag wins, then a hover preview, then keyboard focus
  // parked at the committed playhead — otherwise no local focus at all.
  const targetCenter = dragging ? playhead : hoverFocus !== null ? hoverFocus : focused ? playhead : null;

  const radiusRef = useRef(0);
  const centerRef = useRef(playhead);
  const rafRef = useRef(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = VIEW_W * dpr;
    canvas.height = VIEW_H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    const targetRadius = targetCenter === null ? 0 : FOCUS_RADIUS_COLS;
    if (targetCenter !== null) centerRef.current = targetCenter;

    const draw = () => {
      ctx.clearRect(0, 0, VIEW_W, VIEW_H);

      ctx.strokeStyle = tokens.border;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(0, VIEW_H / 2 + 0.5);
      ctx.lineTo(VIEW_W, VIEW_H / 2 + 0.5);
      ctx.stroke();

      const colW = VIEW_W / BASE_COLS;
      ctx.font = `${Math.max(8, colW * 0.95)}px "GeistMono", ui-monospace, monospace`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = tokens.fg;

      const rowH = (VIEW_H * 0.42) / MAX_ROWS;

      let x = 0;
      for (let i = 0; i < BASE_COLS; i++) {
        const t0 = i / BASE_COLS;
        const t1 = (i + 1) / BASE_COLS;
        const center = (i + 0.5) / BASE_COLS;
        const distCols = Math.abs(center - centerRef.current) * BASE_COLS;
        const subdivided = radiusRef.current > 0.5 && distCols < radiusRef.current;
        const parts = subdivided ? SUBDIVIDE : 1;
        const partW = colW / parts;

        for (let p = 0; p < parts; p++) {
          const pt0 = t0 + ((t1 - t0) * p) / parts;
          const pt1 = t0 + ((t1 - t0) * (p + 1)) / parts;
          const amp = peakIn(pt0, pt1);
          const rows = Math.max(1, Math.round(amp * MAX_ROWS));
          const glyphIdx = Math.min(CHARSET.length - 1, Math.floor(amp * (CHARSET.length - 1)));
          const glyph = CHARSET[glyphIdx];
          const cx = x + partW / 2;
          for (let r = 0; r < rows; r++) {
            const yy = VIEW_H / 2 - (r + 0.5) * rowH;
            ctx.fillText(glyph, cx, yy);
            ctx.fillText(glyph, cx, VIEW_H - yy);
          }
          x += partW;
        }
      }

      const px = playhead * VIEW_W;
      ctx.strokeStyle = tokens.accent;
      ctx.lineWidth = focused || dragging ? 2 : 1.5;
      ctx.beginPath();
      ctx.moveTo(px + 0.5, 6);
      ctx.lineTo(px + 0.5, VIEW_H - 6);
      ctx.stroke();

      let animating = false;
      if (reducedRef.current) {
        radiusRef.current = targetRadius;
      } else {
        const dr = targetRadius - radiusRef.current;
        if (Math.abs(dr) > EASE_EPS) {
          radiusRef.current += dr * 0.25;
          animating = true;
        } else {
          radiusRef.current = targetRadius;
        }
      }

      if (animating) rafRef.current = requestAnimationFrame(draw);
    };

    draw();
    return () => cancelAnimationFrame(rafRef.current);
  }, [tokens, playhead, targetCenter, focused, dragging]);

  return (
    <div className={`ns-was relative select-none ${className}`} style={{ width: VIEW_W, maxWidth: "100%" }}>
      <style>{CSS}</style>
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        style={{ width: VIEW_W, height: VIEW_H, maxWidth: "100%", display: "block" }}
      />
      <div
        ref={wrapRef}
        id={`${uid}-slider`}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(playhead * 100)}
        aria-valuetext={formatTime(playhead, duration)}
        aria-orientation="horizontal"
        className="ns-was-hit absolute inset-0 cursor-pointer touch-none outline-none"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerUp}
        onPointerLeave={handlePointerLeave}
        onKeyDown={handleKeyDown}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          setFocused(false);
          setDragging(false);
        }}
      />
    </div>
  );
}

const CSS = `
.ns-was-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
`;
Use when

a scrub position control over a waveform where the region under the pointer should visibly resolve to finer detail while the rest stays a coarse print — a spatial resolution effect, not a hover brighten; pick voice-recorder-meter instead for a real Web Audio capture meter with no scrub/drag at all, or scrubber-film-strip for a playback scrubber whose mechanic is discrete frame-snap vs. continuous glide rather than local resolution.

Build spec

<WaveformAsciiScrub value? defaultValue? duration? onValueChange? label? className?> draws a fixed-width canvas waveform from a locally synthesised (no audio API, no network) layered-sine amplitude function sampled peak-in-bucket across 64 base glyph columns, each rendered as a Geist Mono density character from ' .:-=+*#%@' stacked into a symmetric bar mirrored above and below the centerline. At rest every column is exactly that one coarse sample. Dragging the accent playhead (a role=slider div overlaying the canvas, pointer and keyboard driven), hovering anywhere over the strip, or holding it in keyboard focus subdivides the ~6 base columns nearest that position into 3 finer sub-columns apiece, each independently re-sampling its own narrower time span — genuinely finer detail, not an interpolated blur — while columns outside that radius stay at base resolution; the radius eases in and out over roughly 250ms rather than snapping, and collapses back to zero (fully coarse) the instant nothing is hovered, dragged or focused. Keyboard: ArrowLeft/Right step 1%, PageUp/PageDown step 5%, Home/End jump to the ends; aria-valuenow/aria-valuetext (an M:SS derived from the duration prop) update on every commit. Colors read live from --foreground/--border/--accent via getComputedStyle, resynced on a documentElement class MutationObserver — no hardcoded hex. Under prefers-reduced-motion the resolution radius snaps instantly with no eased rAF loop. Zero dependencies.

Tags
waveformasciiaudiocanvasscrubsliderdragaccessibility