Skip to main content

ns-ui

Spectrogram ASCII Bands

A scrolling spectrogram in monospace ink: a real 512-point FFT grouped into log-spaced bands, each with its own adaptive noise floor so quiet bands stay dark and only the actual harmonic structure prints as bright glyph runs.

Usage

registry/core/spectrogram-ascii-bands/demo.tsx
"use client";

import { SpectrogramAsciiBands } from "./component";

export default function SpectrogramAsciiBandsDemo() {
  return (
    <div className="flex min-h-screen w-full items-center justify-center bg-background px-8 py-10">
      <div className="w-full">
        <div className="mb-4 flex items-baseline justify-between gap-4 font-mono">
          <h2 className="text-sm text-foreground">Room mic — channel A</h2>
          <span className="text-xs text-ns-muted">
            16 kHz · 512-pt FFT · 32 log bands · 50 ms hop
          </span>
        </div>
        <SpectrogramAsciiBands />
        <p className="mt-3 font-mono text-xs text-ns-muted">
          Time runs right to left; the newest column is at the right edge.
        </p>
      </div>
    </div>
  );
}

Component

registry/core/spectrogram-ascii-bands/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// SpectrogramAsciiBands — a scrolling spectrogram printed in monospace ink.
//
// The analysis is real: a 512-point radix-2 Cooley–Tukey FFT (inlined, no
// dependency) over a Hann-windowed frame, magnitudes in dB, grouped into 32
// log-spaced bands. The mechanism that makes it READABLE rather than a
// uniform medium-density wash is the per-band adaptive floor: each band runs
// its own slow AGC that falls fast (0.02) and rises slowly (0.002) toward the
// band's own dB, so it tracks the QUIET baseline. A steady hiss sinks into
// its own floor and disappears; only structure that stands above its band's
// baseline prints at all, and anything under floor + 12 dB prints nothing. Most
// of the frame is empty by construction.
//
// The pointer never distorts the field — the cells are measurements, and
// bending them would lie. It parks a cursor on a column and reads that
// column's peak out in the readout instead.
// ---------------------------------------------------------------------------

const RAMP = " .:-=+*#%@";
const ALPHA_BUCKETS = 6;

const FFT_N = 512;
const LOG2N = 9;
const BANDS = 32;
const MIN_BIN = 2;
const MAX_BIN = 200;
const HOP_MS = 50;
const SEED_COLS = 240;

const FLOOR_INIT = -70; // dB — seeded low so the AGC warms UP into each band's baseline
const FLOOR_MAX = -35; // dB — the AGC stays in the quiet regime; it may not climb onto the subject
const WARMUP_HOPS = 300; // analysis-only hops before any visible column is committed
const WARMUP_BOOST = 12; // rise coefficient multiplier during warmup only
const FLOOR_FALL = 0.02;
const FLOOR_RISE = 0.002;
const GATE_DB = 12; // dB above floor before a cell prints anything at all
const SPAN_DB = 32; // dB above floor mapped across the full ramp

const CURSOR_TAU = 0.4; // s — cursor / readout crossfade time constant
const DT_MAX = 0.25;
const MAX_HOPS_PER_FRAME = 4;

const SYNTH_SR = 16000;
const LABEL_BANDS = [0, 8, 16, 24, 31];

// -- FFT tables -------------------------------------------------------------
const REV = new Uint16Array(FFT_N);
for (let i = 0; i < FFT_N; i++) {
  let r = 0;
  for (let b = 0; b < LOG2N; b++) r |= ((i >> b) & 1) << (LOG2N - 1 - b);
  REV[i] = r;
}
const TW_COS = new Float32Array(FFT_N / 2);
const TW_SIN = new Float32Array(FFT_N / 2);
for (let i = 0; i < FFT_N / 2; i++) {
  TW_COS[i] = Math.cos((-2 * Math.PI * i) / FFT_N);
  TW_SIN[i] = Math.sin((-2 * Math.PI * i) / FFT_N);
}
const HANN = new Float32Array(FFT_N);
for (let n = 0; n < FFT_N; n++) {
  HANN[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (FFT_N - 1));
}

// Band k covers bins BAND_START[k] .. BAND_START[k+1]. The ratio 100^(1/32) is
// under 1.16, so the log formula's first ten starts collide on bins 2..5 — left
// as-is that prints ten byte-identical rows at the bottom of the field and a
// max() over an empty range yields -Infinity. Each start is therefore forced
// strictly above its predecessor, which makes the lowest bands one bin wide
// (the FFT's own resolution limit down there) and log-spaced from there up.
const BAND_START = new Int32Array(BANDS + 1);
{
  let prev = -1;
  for (let k = 0; k <= BANDS; k++) {
    let v = Math.floor(MIN_BIN * Math.pow(MAX_BIN / MIN_BIN, k / BANDS));
    if (v <= prev) v = prev + 1;
    BAND_START[k] = v;
    prev = v;
  }
}

function fft(re: Float32Array, im: Float32Array) {
  for (let i = 0; i < FFT_N; i++) {
    const j = REV[i]!;
    if (j > i) {
      const tr = re[i]!;
      re[i] = re[j]!;
      re[j] = tr;
      const ti = im[i]!;
      im[i] = im[j]!;
      im[j] = ti;
    }
  }
  for (let size = 2; size <= FFT_N; size <<= 1) {
    const half = size >> 1;
    const step = FFT_N / size;
    for (let i = 0; i < FFT_N; i += size) {
      for (let j = 0; j < half; j++) {
        const k = j * step;
        const wr = TW_COS[k]!;
        const wi = TW_SIN[k]!;
        const a = i + j;
        const b = a + half;
        const xr = re[b]! * wr - im[b]! * wi;
        const xi = re[b]! * wi + im[b]! * wr;
        re[b] = re[a]! - xr;
        im[b] = im[a]! - xi;
        re[a] = re[a]! + xr;
        im[a] = im[a]! + xi;
      }
    }
  }
}

function mulberry32(seed: number) {
  let a = seed;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * Deterministic stand-in for a microphone: two vowel-like formants at 320 Hz
 * and 1.9 kHz with a shared ±8% vibrato at 0.4 Hz, an exponential 200→4000 Hz
 * sweep looping every 9 s, and pink-ish noise at −48 dB. Nothing here calls
 * getUserMedia, so the preview never meets a permission prompt.
 */
function createSyntheticSource() {
  const rand = mulberry32(0x5c09a1);
  let t = 0;
  let p1 = 0;
  let p2 = 0;
  let ps = 0;
  let pink = 0;
  const dt = 1 / SYNTH_SR;
  return (n: number, write: (v: number) => void) => {
    for (let i = 0; i < n; i++) {
      t += dt;
      const vib = 1 + 0.08 * Math.sin(2 * Math.PI * 0.4 * t);
      const fs = 200 * Math.pow(20, (t % 9) / 9);
      p1 += 2 * Math.PI * 320 * vib * dt;
      p2 += 2 * Math.PI * 1900 * vib * dt;
      ps += 2 * Math.PI * fs * dt;
      pink = pink * 0.96 + (rand() * 2 - 1) * 0.04;
      write(
        0.5 * Math.sin(p1) +
          0.32 * Math.sin(p2) +
          0.3 * Math.sin(ps) +
          pink * 0.012
      );
    }
  };
}

export interface SpectrogramAsciiBandsProps {
  /**
   * Where samples come from: a live `AnalyserNode`, or a provider that fills
   * the passed Float32Array with the newest 512 time-domain samples. Omitted
   * ⇒ the built-in deterministic synthetic signal.
   */
  source?: AnalyserNode | ((out: Float32Array) => void);
  /** Row height in px; the field is (32 + 2) rows tall. Default 16. */
  cellSize?: number;
  /** Accessible name for the focusable readout region. */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function SpectrogramAsciiBands({
  source,
  cellSize = 16,
  label = "Spectrogram readout — arrow keys step the cursor through the history",
  className = "",
}: SpectrogramAsciiBandsProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const readoutRef = useRef<HTMLDivElement>(null);
  const liveRef = useRef<HTMLSpanElement>(null);
  const cursorTextRef = useRef<HTMLSpanElement>(null);
  const gutterRef = useRef<HTMLDivElement>(null);
  const srRef = useRef<HTMLSpanElement>(null);

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

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const sampleRate =
      source instanceof AnalyserNode ? source.context.sampleRate : SYNTH_SR;
    const hopSamples = Math.max(1, Math.round((sampleRate * HOP_MS) / 1000));
    const binHz = sampleRate / FFT_N;

    const fmtHz = (hz: number) =>
      hz >= 1000 ? `${(hz / 1000).toFixed(1)} kHz` : `${Math.round(hz)} Hz`;

    // gutter labels depend on the source's sample rate, so they are written
    // here rather than baked into the markup
    const gutter = gutterRef.current;
    if (gutter) {
      for (const el of Array.from(gutter.children)) {
        const b = Number((el as HTMLElement).dataset.band);
        (el as HTMLElement).textContent = fmtHz(BAND_START[b]! * binHz);
      }
    }

    // -- analysis state ------------------------------------------------------
    const ring = new Float32Array(FFT_N); // newest 512 samples, modular
    let ringHead = 0;
    const re = new Float32Array(FFT_N);
    const im = new Float32Array(FFT_N);
    const floors = new Float32Array(BANDS).fill(FLOOR_INIT);
    const bandDb = new Float32Array(BANDS);
    const synth = createSyntheticSource();
    const writeSample = (v: number) => {
      ring[ringHead] = v;
      ringHead = (ringHead + 1) % FFT_N;
    };
    const external =
      source instanceof AnalyserNode
        ? (() => {
            const buf = new Float32Array(source.fftSize);
            return () => {
              source.getFloatTimeDomainData(buf);
              const off = Math.max(0, buf.length - FFT_N);
              for (let i = 0; i < FFT_N; i++) writeSample(buf[off + i] ?? 0);
            };
          })()
        : typeof source === "function"
          ? (() => {
              const buf = new Float32Array(FFT_N);
              return () => {
                source(buf);
                for (let i = 0; i < FFT_N; i++) writeSample(buf[i] ?? 0);
              };
            })()
          : null;

    // -- history ring --------------------------------------------------------
    let cols = 0;
    let cells = new Uint8Array(0); // ramp index per (col, band)
    let peakBand = new Uint8Array(0);
    let peakDb = new Float32Array(0);
    let head = 0; // oldest column

    let riseBoost = 1;

    const analyse = () => {
      if (external) external();
      else synth(hopSamples, writeSample);

      for (let n = 0; n < FFT_N; n++) {
        re[n] = ring[(ringHead + n) % FFT_N]! * HANN[n]!;
        im[n] = 0;
      }
      fft(re, im);

      for (let k = 0; k < BANDS; k++) {
        const start = BAND_START[k]!;
        const end = Math.max(start + 1, BAND_START[k + 1]!);
        let best = -Infinity;
        for (let b = start; b < end; b++) {
          const mag = Math.sqrt(re[b]! * re[b]! + im[b]! * im[b]!) / (FFT_N / 4);
          const db = 20 * Math.log10(mag + 1e-9);
          if (db > best) best = db;
        }
        bandDb[k] = best;
        const f = floors[k]!;
        floors[k] = Math.min(
          FLOOR_MAX,
          f + (best - f) * (best < f ? FLOOR_FALL : FLOOR_RISE * riseBoost)
        );
      }
    };

    const commitColumn = () => {
      if (cols === 0) return;
      const col = head;
      head = (head + 1) % cols;
      const base = col * BANDS;
      let bestBand = 0;
      let bestExcess = -Infinity;
      for (let k = 0; k < BANDS; k++) {
        const excess = bandDb[k]! - floors[k]!;
        let idx = 0;
        if (excess >= GATE_DB) {
          idx = Math.round((excess / SPAN_DB) * 9);
          idx = idx < 1 ? 1 : idx > 9 ? 9 : idx;
        }
        cells[base + k] = idx;
        if (excess > bestExcess) {
          bestExcess = excess;
          bestBand = k;
        }
      }
      peakBand[col] = bestExcess >= GATE_DB ? bestBand : 255;
      peakDb[col] = bandDb[bestBand]!;
    };

    const hop = () => {
      analyse();
      commitColumn();
    };

    // -- layout / tokens -----------------------------------------------------
    let fg = "currentColor";
    let accent = "currentColor";
    let cellW = cellSize * 0.6;
    const cellH = cellSize;
    const rows = BANDS + 1; // row 0 = headroom, rows 1..32 = field (band 31 at top)
    let sized = false;
    let disposed = false;
    let warmed = false;

    const readTokens = () => {
      fg = getComputedStyle(canvas).color;
      accent =
        getComputedStyle(document.documentElement)
          .getPropertyValue("--ns-accent")
          .trim() || fg;
    };

    const bucketLists: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);

    const resize = () => {
      const { width } = canvas.getBoundingClientRect();
      const height = rows * cellH;
      if (width < 2) {
        sized = false;
        return;
      }
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      const fontFamily = getComputedStyle(canvas).fontFamily;
      const off = document.createElement("canvas").getContext("2d");
      if (off) {
        off.font = `${cellSize}px ${fontFamily}`;
        cellW = Math.max(4, off.measureText("MMMMMMMMMM").width / 10);
      }
      ctx.font = `${cellSize}px ${fontFamily}`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      const nextCols = Math.max(16, Math.floor(width / cellW));
      if (nextCols !== cols) {
        cols = nextCols;
        cells = new Uint8Array(cols * BANDS);
        peakBand = new Uint8Array(cols).fill(255);
        peakDb = new Float32Array(cols);
        head = 0;
        if (!warmed) {
          // Settle the per-band floors BEFORE any visible column is committed,
          // with a boosted rise coefficient so ~300 hops reach where the real
          // 0.002 coefficient would need ~2000. Without it every band's floor
          // is still down at its -70 dB seed, everything clears the gate, and
          // the first visible field is a uniform medium-density wash.
          riseBoost = WARMUP_BOOST;
          for (let i = 0; i < WARMUP_HOPS; i++) analyse();
          riseBoost = 1;
          warmed = true;
        }
        for (let i = 0; i < Math.max(cols, SEED_COLS); i++) hop();
      }
      sized = true;
    };

    // -- cursor state (refs only; nothing on the hot path is React state) -----
    let cursorCol: number | null = null; // screen column, 0 = oldest/left
    let cursorAlpha = 0;

    const setReadout = () => {
      const live = liveRef.current;
      const cur = cursorTextRef.current;
      if (live) {
        // running summary: the loudest band across the whole visible window
        let bb = 255;
        let bd = -Infinity;
        for (let j = 0; j < cols; j++) {
          const c = (head + j) % cols;
          if (peakBand[c] === 255) continue;
          if (peakDb[c]! > bd) {
            bd = peakDb[c]!;
            bb = peakBand[c]!;
          }
        }
        live.textContent =
          bb === 255
            ? `no signal above floor · ${((cols * HOP_MS) / 1000).toFixed(1)} s window`
            : `peak ${fmtHz(BAND_START[bb]! * binHz)}  ${bd >= 0 ? "" : "−"}${Math.abs(bd).toFixed(0)} dB  · ${((cols * HOP_MS) / 1000).toFixed(1)} s window`;
      }
      if (cur) {
        if (cursorCol === null) cur.textContent = "";
        else {
          const c = (head + cursorCol) % cols;
          const age = ((cols - 1 - cursorCol) * HOP_MS) / 1000;
          const b = peakBand[c];
          cur.textContent =
            b === undefined || b === 255
              ? `— below floor  ${age.toFixed(1)} s ago`
              : `${fmtHz(BAND_START[b]! * binHz)}  ${peakDb[c]! >= 0 ? "" : "−"}${Math.abs(peakDb[c]!).toFixed(0)} dB  ${age.toFixed(1)} s ago`;
        }
      }
    };

    const draw = () => {
      if (!sized) return;
      const w = cols * cellW;
      ctx.clearRect(0, 0, w + cellW, rows * cellH);

      // field — pass one buckets, pass two draws one alpha per bucket
      for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;
      for (let j = 0; j < cols; j++) {
        const c = (head + j) % cols;
        const base = c * BANDS;
        for (let k = 0; k < BANDS; k++) {
          const idx = cells[base + k]!;
          if (idx === 0) continue;
          const bucket = Math.min(
            ALPHA_BUCKETS - 1,
            Math.floor(((idx - 1) / 9) * ALPHA_BUCKETS)
          );
          bucketLists[bucket]!.push((j << 8) | (k << 2) | 0);
        }
      }
      ctx.fillStyle = fg;
      for (let b = 0; b < ALPHA_BUCKETS; b++) {
        const list = bucketLists[b]!;
        ctx.globalAlpha = 0.22 + (b / (ALPHA_BUCKETS - 1)) * 0.78;
        for (let n = 0; n < list.length; n++) {
          const packed = list[n]!;
          const j = packed >> 8;
          const k = (packed >> 2) & 63;
          const c = (head + j) % cols;
          ctx.fillText(
            RAMP[cells[c * BANDS + k]!]!,
            j * cellW + cellW / 2,
            (1 + (BANDS - 1 - k)) * cellH + cellH * 0.5
          );
        }
      }

      // pitch track — the argmax band of each column, one accent dot drawn ON
      // its own row (a one-row strip cannot encode WHICH band won, so it
      // degenerates into a solid rule). Drawn after the field so it reads as a
      // contour riding over the dominant ribbon.
      ctx.fillStyle = accent;
      ctx.globalAlpha = 0.9;
      for (let j = 0; j < cols; j++) {
        const c = (head + j) % cols;
        const b = peakBand[c];
        if (b === undefined || b === 255) continue;
        ctx.fillText(
          "·",
          j * cellW + cellW / 2,
          (1 + (BANDS - 1 - b)) * cellH + cellH * 0.5
        );
      }

      // cursor — a measurement marker, never a distortion of the field
      if (cursorCol !== null && cursorAlpha > 0.01) {
        ctx.fillStyle = accent;
        ctx.globalAlpha = cursorAlpha * 0.8;
        const x = cursorCol * cellW + cellW / 2;
        for (let r = 1; r < rows; r++) ctx.fillText("┊", x, r * cellH + cellH * 0.5);
      }
      ctx.globalAlpha = 1;
    };

    const applyFade = () => {
      const cur = cursorTextRef.current;
      const live = liveRef.current;
      if (cur) cur.style.opacity = String(cursorAlpha);
      if (live) live.style.opacity = String(1 - cursorAlpha);
    };

    // -- loop ----------------------------------------------------------------
    let raf = 0;
    let last = 0;
    let acc = 0;

    const loop = (now: number) => {
      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;
      last = now;
      acc += dt * 1000;
      let hops = 0;
      while (acc >= HOP_MS && hops < MAX_HOPS_PER_FRAME) {
        acc -= HOP_MS;
        hop();
        hops++;
      }
      if (acc > HOP_MS * MAX_HOPS_PER_FRAME) acc = 0;
      const target = cursorCol !== null ? 1 : 0;
      cursorAlpha += (target - cursorAlpha) * Math.min(1, dt / CURSOR_TAU);
      if (cursorCol === null && cursorAlpha < 0.01) cursorAlpha = 0;
      if (hops > 0 || cursorAlpha > 0) setReadout();
      applyFade();
      draw();
      if (!document.hidden) raf = requestAnimationFrame(loop);
    };

    const colFromClientX = (clientX: number) => {
      const rect = canvas.getBoundingClientRect();
      const j = Math.floor((clientX - rect.left) / cellW);
      return j < 0 ? 0 : j > cols - 1 ? cols - 1 : j;
    };
    const onPointerMove = (e: PointerEvent) => {
      cursorCol = colFromClientX(e.clientX);
    };
    const onPointerLeave = () => {
      cursorCol = null;
    };

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      e.preventDefault();
      const step = e.key === "ArrowLeft" ? -1 : 1;
      const from = cursorCol === null ? cols - 1 : cursorCol + step;
      cursorCol = from < 0 ? 0 : from > cols - 1 ? cols - 1 : from;
      setReadout();
      // announce only on a deliberate key step — the 20 Hz running summary is
      // never put in the live region, which would be a screen-reader firehose
      const sr = srRef.current;
      if (sr) sr.textContent = cursorTextRef.current?.textContent ?? "";
      if (reduced) {
        cursorAlpha = 1;
        setReadout();
        applyFade();
        draw();
      }
    };
    const onBlur = () => {
      if (cursorCol !== null && document.activeElement !== readoutRef.current) {
        cursorCol = null;
        const sr = srRef.current;
        if (sr) sr.textContent = "";
        if (reduced) {
          cursorAlpha = 0;
          setReadout();
          applyFade();
          draw();
        }
      }
    };

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) draw();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        setReadout();
        if (reduced) draw();
      }, 150);
    };

    const onVis = () => {
      // the frame queued before the tab hid is still pending — without this the
      // resumed loop runs alongside it and the hop rate silently doubles
      cancelAnimationFrame(raf);
      if (!document.hidden && !reduced && sized) {
        last = 0;
        acc = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const readout = readoutRef.current;
    readout?.addEventListener("keydown", onKeyDown);
    readout?.addEventListener("blur", onBlur);
    window.addEventListener("resize", onResize);
    document.addEventListener("visibilitychange", onVis);
    if (!reduced) {
      canvas.addEventListener("pointermove", onPointerMove);
      canvas.addEventListener("pointerleave", onPointerLeave);
    }

    document.fonts.ready.then(() => {
      if (disposed) return;
      readTokens();
      resize();
      setReadout();
      applyFade();
      draw();
      if (!reduced) raf = requestAnimationFrame(loop);
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      readout?.removeEventListener("keydown", onKeyDown);
      readout?.removeEventListener("blur", onBlur);
      window.removeEventListener("resize", onResize);
      document.removeEventListener("visibilitychange", onVis);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerleave", onPointerLeave);
    };
  }, [source, cellSize]);

  const fieldHeight = (BANDS + 1) * cellSize;

  return (
    <div className={`ns-sab w-full font-mono ${className}`}>
      <style>{CSS}</style>
      <div className="flex w-full items-start">
        <div
          ref={gutterRef}
          aria-hidden="true"
          className="relative shrink-0"
          style={{ width: cellSize * 4.2, height: fieldHeight }}
        >
          {LABEL_BANDS.map((b) => (
            <span
              key={b}
              data-band={b}
              className="absolute right-0 flex items-center justify-end pr-2 text-ns-muted"
              style={{
                top: (1 + (BANDS - 1 - b)) * cellSize,
                height: cellSize,
                fontSize: Math.max(9, cellSize * 0.62),
                lineHeight: 1,
              }}
            />
          ))}
        </div>
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className="block min-w-0 flex-1 text-foreground"
          style={{ height: fieldHeight }}
        />
      </div>
      <div
        ref={readoutRef}
        tabIndex={0}
        role="group"
        aria-label={label}
        className="ns-sab-readout relative mt-2 block w-full rounded-sm border border-border px-3 outline-none"
        style={{ height: cellSize * 1.9, fontSize: Math.max(10, cellSize * 0.68) }}
      >
        <span
          ref={liveRef}
          className="absolute inset-0 flex items-center px-3 text-ns-muted"
        />
        <span
          ref={cursorTextRef}
          className="absolute inset-0 flex items-center px-3 text-foreground"
          style={{ opacity: 0 }}
        />
        <span ref={srRef} aria-live="polite" className="ns-sab-sr" />
      </div>
    </div>
  );
}

const CSS = `
.ns-sab-readout:focus { outline: 2px solid var(--ns-accent); outline-offset: 2px; }
.ns-sab-readout:hover { border-color: var(--ns-accent); }
.ns-sab-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/spectrogram-ascii-bands.tsx, so point the import there.

Use when Pick spectrogram-ascii-bands when the subject is the frequency content of a signal over time — a scrolling time-vs-pitch field where steady formants read as horizontal ribbons and a sweep as a diagonal, driven by a real FFT off an AnalyserNode or a sample provider. Pick waveform-ascii-scrub instead when the subject is amplitude over time with a draggable playhead, and sparkline-ascii when a single scalar series just needs a compact inline trend.

Install

npx shadcn add https://design.helpmarq.com/r/spectrogram-ascii-bands.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <SpectrogramAsciiBands source? cellSize? label? className?> as a scrolling ASCII spectrogram over a <canvas>, with a left gutter of frequency labels and a focusable readout strip below. ANALYSIS: a 512-point radix-2 Cooley–Tukey FFT, inlined with no dependency, over a Hann-windowed frame (w[n] = 0.5 - 0.5*cos(2*pi*n/511)) taken from a modular ring of the newest 512 time-domain samples; magnitude is normalised by N/4 and converted with dB = 20*log10(mag + 1e-9). BANDS: 32 log-spaced groups covering bins 2..200, band k starting at floor(2 * (200/2)^(k/32)) and running to the start of band k+1, each band's value being the max dB over its bins. Because 100^(1/32) is under 1.16 the raw formula's first ten starts collide on bins 2..5, which prints ten byte-identical rows and makes a max() over an empty range yield -Infinity, so each start is forced strictly above its predecessor — the lowest bands end up one bin wide, which is honestly the FFT's own resolution limit down there, and log spacing takes over above it. ADAPTIVE FLOOR — this is the sparsity control and the whole reason the field is readable: every band runs its own slow AGC, floor += (dB - floor) * (dB < floor ? 0.02 : 0.002) per 50 ms hop, so it falls onto dips fast and climbs out of them ten times slower and therefore tracks the band's QUIET baseline rather than its mean. A steady hiss or a leakage skirt sinks into its own floor and vanishes instead of producing the uniform medium-density wash that would make the field unreadable; the floor is clamped at -35 dB so it can never climb onto the subject itself. The floors are settled by 300 analysis-only warmup hops with a 12x boosted rise coefficient before any visible column is committed — without that pre-roll every floor is still at its -70 dB seed and the first frame the viewer sees is exactly the wash the AGC exists to prevent. GLYPH: excess = dB - floor; anything under floor + 12 dB prints NOTHING, so most of the frame is empty by construction, and above it idx = clamp(round(excess/32 * 9), 1, 9) indexes the shared ' .:-=+*#%@' density ramp. SCROLL: one new column per 50 ms hop appended into a Uint8Array(cols * 32) ring behind a moving write head, so the field scrolls right-to-left with zero per-frame reallocation and the newest column is always at the right edge. DRAW: two-pass, the same discipline as background-ascii-caustics — pass one walks the ring filling the ramp-index buffer and buckets every non-empty cell into one of 6 alpha buckets; pass two sets ctx.globalAlpha once per bucket and fills only that bucket's cells. Ink is read once from getComputedStyle(canvas).color and the accent from --ns-accent on documentElement, both re-derived on a documentElement class MutationObserver so a theme flip is live; the mono cell width is measured with an offscreen canvas measureText. Monochrome ramp only — no viridis/jet/inferno, no glow. PITCH TRACK: the argmax band of each column is drawn as a single '·' in the accent at that band's OWN row, painted after the field so it reads as a contour riding over the dominant ribbon — put on a separate one-row strip above the field it could not encode which band actually won and degenerated into a solid accent rule. AXIS: a left gutter of frequency labels every 8 bands in muted ink, computed from the source's real sample rate rather than baked in. SOURCE: the source prop takes either an AnalyserNode (read via getFloatTimeDomainData) or a (out: Float32Array) => void sample provider; omitted, it falls back to a deterministic synthetic signal — formants at 320 Hz and 1.9 kHz sharing a ±8% vibrato at 0.4 Hz, an exponential 200→4000 Hz sweep looping every 9 s, and pink-ish noise well below both — so nothing ever calls getUserMedia and a screenshot gate never meets a permission prompt. At rest that reads immediately as a spectrogram: two steady horizontal ribbons crossed by a slow rising diagonal against a mostly empty field. INTERACTION: hovering a column parks a '┊' cursor on it and prints that column's peak as '1.9 kHz −22 dB 0.8 s ago'; the field itself is never distorted by the pointer, because the cells are measurements and bending them would lie. On pointer-out the cursor and its line fade out on a 0.4 s time constant while the running peak summary for the whole visible window crossfades back in — an ease, not a hard cut. The readout region is focusable with a visible accent focus ring, and ArrowLeft/ArrowRight step the cursor column by column through the history; a visually hidden aria-live=polite span is written ONLY on a deliberate key step, never from the 20 Hz running summary, which would be a screen-reader firehose. prefers-reduced-motion renders exactly ONE static frame of a pre-seeded 240-column history and skips the rAF loop and the pointer listeners entirely, keyboard stepping still redrawing on demand. The loop pauses on document.hidden and resumes on visibilitychange. Direct-DOM rAF, zero React state on the hot path. Props: source (AnalyserNode or sample provider, default the synthetic signal), cellSize (row height px, default 16; the field is 34 rows tall), label (accessible name for the readout region), className.

Props

PropTypeDefaultDescription
source?AnalyserNode | ((out: Float32Array) => void)—Where samples come from: a live `AnalyserNode`, or a provider that fills the passed Float32Array with the newest 512 time-domain samples. Omitted ⇒ the built-in deterministic synthetic signal.
cellSize?number16Row height in px; the field is (32 + 2) rows tall. Default 16.
label?string"Spectrogram readout — arrow keys step the cursor through the history"Accessible name for the focusable readout region.
className?string—extra classes merged onto the rendered root element