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.
npx shadcn add https://design.helpmarq.com /r/spectrogram-ascii-bands.jsonregistry/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;
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("--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-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-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(--accent); outline-offset: 2px; }
.ns-sab-readout:hover { border-color: var(--accent); }
.ns-sab-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
`;
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.
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 --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.