yugodesign

Sound·11.4

Waveform Scope

What is actually reaching the speakers.

Outputno signal
waveform-scope

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add @yugo/sound

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/waveform-scope.json

Usage

stats.tsx
"use client";

import { WaveformScope, useWaveformScope } from "@/components/yugo/waveform-scope";

export function SoundPanel() {
  return <WaveformScope label="Output" height={158} />;
}

export function BareScope() {
  const { canvasRef, live, lastCue } = useWaveformScope({ idleAfter: 1200 });

  return (
    <figure data-live={live}>
      <canvas ref={canvasRef} className="h-24 w-full text-slate-700" />
      <figcaption>{lastCue ?? "no signal"}</figcaption>
    </figure>
  );
}

Source

components/yugo/waveform-scope.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { getAnalyser, subscribe, type SoundName } from "@yugo/sound";

const IDLE_AFTER = 900;
const SILENCE = 0.004;
const TRACE_GAIN = 2.5;
const GRID_COLUMNS = 12;
const LINE_WIDTH = 1.5;

function prefersReduced(): boolean {
  if (typeof window === "undefined") return false;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function deviation(samples: Uint8Array): number {
  let peak = 0;
  for (let i = 0; i < samples.length; i += 1) {
    const value = Math.abs(samples[i] / 128 - 1);
    if (value > peak) peak = value;
  }
  return peak;
}

export type UseWaveformScopeOptions = {
  idleAfter?: number;
  gain?: number;
};

export function useWaveformScope({
  idleAfter = IDLE_AFTER,
  gain = TRACE_GAIN,
}: UseWaveformScopeOptions = {}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const analyser = useRef<AnalyserNode | null>(null);
  const samples = useRef<Uint8Array<ArrayBuffer> | null>(null);
  const frame = useRef(0);
  const lastSound = useRef(0);
  const [live, setLive] = useState(false);
  const [lastCue, setLastCue] = useState<SoundName | null>(null);

  const paint = useCallback(
    (data: Uint8Array<ArrayBuffer> | null) => {
      const canvas = canvasRef.current;
      const context = canvas?.getContext("2d");
      if (!canvas || !context) return;

      const { width, height } = canvas;
      const dpr = width / Math.max(1, canvas.clientWidth);
      const stroke = getComputedStyle(canvas).color;
      const middle = height / 2;

      context.clearRect(0, 0, width, height);

      context.globalAlpha = 0.09;
      context.strokeStyle = stroke;
      context.lineWidth = 1;
      for (let i = 1; i < GRID_COLUMNS; i += 1) {
        const x = Math.round((width / GRID_COLUMNS) * i) + 0.5;
        context.beginPath();
        context.moveTo(x, 0);
        context.lineTo(x, height);
        context.stroke();
      }

      context.globalAlpha = 1;
      context.strokeStyle = stroke;
      context.lineWidth = LINE_WIDTH * dpr;
      context.lineJoin = "round";
      context.beginPath();

      if (!data) {
        context.globalAlpha = 0.32;
        context.moveTo(0, middle);
        context.lineTo(width, middle);
        context.stroke();
        return;
      }

      const step = width / (data.length - 1);
      for (let i = 0; i < data.length; i += 1) {
        const amplitude = (data[i] / 128 - 1) * gain;
        const y = middle - amplitude * middle;
        if (i === 0) context.moveTo(0, y);
        else context.lineTo(i * step, y);
      }
      context.stroke();
    },
    [gain],
  );

  const measure = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
  }, []);

  const loop = useCallback(() => {
    const node = analyser.current;
    const buffer = samples.current;
    if (!node || !buffer) return;

    node.getByteTimeDomainData(buffer);
    const level = deviation(buffer);
    if (level > SILENCE) lastSound.current = performance.now();

    if (performance.now() - lastSound.current > idleAfter) {
      frame.current = 0;
      setLive(false);
      paint(null);
      return;
    }

    paint(buffer);
    frame.current = requestAnimationFrame(loop);
  }, [idleAfter, paint]);

  const start = useCallback(() => {
    if (prefersReduced()) return;
    if (!analyser.current) {
      const node = getAnalyser();
      if (!node) return;
      analyser.current = node;
      samples.current = new Uint8Array(node.fftSize);
    }
    lastSound.current = performance.now();
    if (frame.current) return;
    setLive(true);
    frame.current = requestAnimationFrame(loop);
  }, [loop]);

  useEffect(() => {
    measure();
    paint(null);
    const canvas = canvasRef.current;
    if (!canvas) return;
    const observer = new ResizeObserver(() => {
      measure();
      if (!frame.current) paint(null);
    });
    observer.observe(canvas);
    return () => observer.disconnect();
  }, [measure, paint]);

  useEffect(
    () =>
      subscribe((sound) => {
        setLastCue(sound);
        start();
      }),
    [start],
  );

  useEffect(
    () => () => {
      if (frame.current) cancelAnimationFrame(frame.current);
      frame.current = 0;
    },
    [],
  );

  return { canvasRef, live, lastCue, start };
}

export type WaveformScopeProps = {
  label?: string;
  height?: number;
  idleAfter?: number;
  gain?: number;
  className?: string;
};

export function WaveformScope({
  label = "Output",
  height = 132,
  idleAfter = IDLE_AFTER,
  gain = TRACE_GAIN,
  className = "",
}: WaveformScopeProps) {
  const { canvasRef, live, lastCue } = useWaveformScope({ idleAfter, gain });

  return (
    <div className={`w-full ${className}`}>
      <div className="mb-2 flex items-baseline justify-between gap-3">
        <span className="text-[12.5px] text-stone-500 dark:text-stone-400">
          {label}
        </span>
        <span className="grid justify-items-end">
          <span
            aria-hidden
            className="invisible col-start-1 row-start-1 font-mono text-[11px]"
          >
            no signal
          </span>
          <span
            className="col-start-1 row-start-1 font-mono text-[11px]"
            style={{ color: live ? "#4568FF" : "rgb(120 113 108)" }}
          >
            {lastCue ?? "no signal"}
          </span>
        </span>
      </div>
      <div
        className="w-full overflow-hidden rounded-[10px] bg-stone-100 dark:bg-white/[0.06]"
        style={{ height }}
      >
        <canvas
          ref={canvasRef}
          role="img"
          aria-label={
            lastCue ? `Waveform of the ${lastCue} cue` : "Waveform, no signal"
          }
          className="block h-full w-full text-stone-700 dark:text-stone-200"
        />
      </div>
    </div>
  );
}

Props

label"Output"
string

Heading above the trace. The canvas carries its own aria-label naming the cue being drawn.

height132
number

CSS pixels. The backing store is sized separately, from the element's real box at device pixel ratio.

idleAfter900
number

Milliseconds below the noise floor before the frame loop cancels itself and the trace flattens.

gain2.5
number

Vertical scale on the trace only, so a quiet cue is still legible. It never touches what is actually played.

className""
string

Appended last to the outer frame.