{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spectrogram-ascii-bands",
  "title": "Spectrogram ASCII Bands",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/spectrogram-ascii-bands/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SpectrogramAsciiBands — a scrolling spectrogram printed in monospace ink.\n//\n// The analysis is real: a 512-point radix-2 Cooley–Tukey FFT (inlined, no\n// dependency) over a Hann-windowed frame, magnitudes in dB, grouped into 32\n// log-spaced bands. The mechanism that makes it READABLE rather than a\n// uniform medium-density wash is the per-band adaptive floor: each band runs\n// its own slow AGC that falls fast (0.02) and rises slowly (0.002) toward the\n// band's own dB, so it tracks the QUIET baseline. A steady hiss sinks into\n// its own floor and disappears; only structure that stands above its band's\n// baseline prints at all, and anything under floor + 12 dB prints nothing. Most\n// of the frame is empty by construction.\n//\n// The pointer never distorts the field — the cells are measurements, and\n// bending them would lie. It parks a cursor on a column and reads that\n// column's peak out in the readout instead.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\nconst ALPHA_BUCKETS = 6;\n\nconst FFT_N = 512;\nconst LOG2N = 9;\nconst BANDS = 32;\nconst MIN_BIN = 2;\nconst MAX_BIN = 200;\nconst HOP_MS = 50;\nconst SEED_COLS = 240;\n\nconst FLOOR_INIT = -70; // dB — seeded low so the AGC warms UP into each band's baseline\nconst FLOOR_MAX = -35; // dB — the AGC stays in the quiet regime; it may not climb onto the subject\nconst WARMUP_HOPS = 300; // analysis-only hops before any visible column is committed\nconst WARMUP_BOOST = 12; // rise coefficient multiplier during warmup only\nconst FLOOR_FALL = 0.02;\nconst FLOOR_RISE = 0.002;\nconst GATE_DB = 12; // dB above floor before a cell prints anything at all\nconst SPAN_DB = 32; // dB above floor mapped across the full ramp\n\nconst CURSOR_TAU = 0.4; // s — cursor / readout crossfade time constant\nconst DT_MAX = 0.25;\nconst MAX_HOPS_PER_FRAME = 4;\n\nconst SYNTH_SR = 16000;\nconst LABEL_BANDS = [0, 8, 16, 24, 31];\n\n// -- FFT tables -------------------------------------------------------------\nconst REV = new Uint16Array(FFT_N);\nfor (let i = 0; i < FFT_N; i++) {\n  let r = 0;\n  for (let b = 0; b < LOG2N; b++) r |= ((i >> b) & 1) << (LOG2N - 1 - b);\n  REV[i] = r;\n}\nconst TW_COS = new Float32Array(FFT_N / 2);\nconst TW_SIN = new Float32Array(FFT_N / 2);\nfor (let i = 0; i < FFT_N / 2; i++) {\n  TW_COS[i] = Math.cos((-2 * Math.PI * i) / FFT_N);\n  TW_SIN[i] = Math.sin((-2 * Math.PI * i) / FFT_N);\n}\nconst HANN = new Float32Array(FFT_N);\nfor (let n = 0; n < FFT_N; n++) {\n  HANN[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (FFT_N - 1));\n}\n\n// Band k covers bins BAND_START[k] .. BAND_START[k+1]. The ratio 100^(1/32) is\n// under 1.16, so the log formula's first ten starts collide on bins 2..5 — left\n// as-is that prints ten byte-identical rows at the bottom of the field and a\n// max() over an empty range yields -Infinity. Each start is therefore forced\n// strictly above its predecessor, which makes the lowest bands one bin wide\n// (the FFT's own resolution limit down there) and log-spaced from there up.\nconst BAND_START = new Int32Array(BANDS + 1);\n{\n  let prev = -1;\n  for (let k = 0; k <= BANDS; k++) {\n    let v = Math.floor(MIN_BIN * Math.pow(MAX_BIN / MIN_BIN, k / BANDS));\n    if (v <= prev) v = prev + 1;\n    BAND_START[k] = v;\n    prev = v;\n  }\n}\n\nfunction fft(re: Float32Array, im: Float32Array) {\n  for (let i = 0; i < FFT_N; i++) {\n    const j = REV[i]!;\n    if (j > i) {\n      const tr = re[i]!;\n      re[i] = re[j]!;\n      re[j] = tr;\n      const ti = im[i]!;\n      im[i] = im[j]!;\n      im[j] = ti;\n    }\n  }\n  for (let size = 2; size <= FFT_N; size <<= 1) {\n    const half = size >> 1;\n    const step = FFT_N / size;\n    for (let i = 0; i < FFT_N; i += size) {\n      for (let j = 0; j < half; j++) {\n        const k = j * step;\n        const wr = TW_COS[k]!;\n        const wi = TW_SIN[k]!;\n        const a = i + j;\n        const b = a + half;\n        const xr = re[b]! * wr - im[b]! * wi;\n        const xi = re[b]! * wi + im[b]! * wr;\n        re[b] = re[a]! - xr;\n        im[b] = im[a]! - xi;\n        re[a] = re[a]! + xr;\n        im[a] = im[a]! + xi;\n      }\n    }\n  }\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/**\n * Deterministic stand-in for a microphone: two vowel-like formants at 320 Hz\n * and 1.9 kHz with a shared ±8% vibrato at 0.4 Hz, an exponential 200→4000 Hz\n * sweep looping every 9 s, and pink-ish noise at −48 dB. Nothing here calls\n * getUserMedia, so the preview never meets a permission prompt.\n */\nfunction createSyntheticSource() {\n  const rand = mulberry32(0x5c09a1);\n  let t = 0;\n  let p1 = 0;\n  let p2 = 0;\n  let ps = 0;\n  let pink = 0;\n  const dt = 1 / SYNTH_SR;\n  return (n: number, write: (v: number) => void) => {\n    for (let i = 0; i < n; i++) {\n      t += dt;\n      const vib = 1 + 0.08 * Math.sin(2 * Math.PI * 0.4 * t);\n      const fs = 200 * Math.pow(20, (t % 9) / 9);\n      p1 += 2 * Math.PI * 320 * vib * dt;\n      p2 += 2 * Math.PI * 1900 * vib * dt;\n      ps += 2 * Math.PI * fs * dt;\n      pink = pink * 0.96 + (rand() * 2 - 1) * 0.04;\n      write(\n        0.5 * Math.sin(p1) +\n          0.32 * Math.sin(p2) +\n          0.3 * Math.sin(ps) +\n          pink * 0.012\n      );\n    }\n  };\n}\n\nexport interface SpectrogramAsciiBandsProps {\n  /**\n   * Where samples come from: a live `AnalyserNode`, or a provider that fills\n   * the passed Float32Array with the newest 512 time-domain samples. Omitted\n   * ⇒ the built-in deterministic synthetic signal.\n   */\n  source?: AnalyserNode | ((out: Float32Array) => void);\n  /** Row height in px; the field is (32 + 2) rows tall. Default 16. */\n  cellSize?: number;\n  /** Accessible name for the focusable readout region. */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SpectrogramAsciiBands({\n  source,\n  cellSize = 16,\n  label = \"Spectrogram readout — arrow keys step the cursor through the history\",\n  className = \"\",\n}: SpectrogramAsciiBandsProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const readoutRef = useRef<HTMLDivElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n  const cursorTextRef = useRef<HTMLSpanElement>(null);\n  const gutterRef = useRef<HTMLDivElement>(null);\n  const srRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const sampleRate =\n      source instanceof AnalyserNode ? source.context.sampleRate : SYNTH_SR;\n    const hopSamples = Math.max(1, Math.round((sampleRate * HOP_MS) / 1000));\n    const binHz = sampleRate / FFT_N;\n\n    const fmtHz = (hz: number) =>\n      hz >= 1000 ? `${(hz / 1000).toFixed(1)} kHz` : `${Math.round(hz)} Hz`;\n\n    // gutter labels depend on the source's sample rate, so they are written\n    // here rather than baked into the markup\n    const gutter = gutterRef.current;\n    if (gutter) {\n      for (const el of Array.from(gutter.children)) {\n        const b = Number((el as HTMLElement).dataset.band);\n        (el as HTMLElement).textContent = fmtHz(BAND_START[b]! * binHz);\n      }\n    }\n\n    // -- analysis state ------------------------------------------------------\n    const ring = new Float32Array(FFT_N); // newest 512 samples, modular\n    let ringHead = 0;\n    const re = new Float32Array(FFT_N);\n    const im = new Float32Array(FFT_N);\n    const floors = new Float32Array(BANDS).fill(FLOOR_INIT);\n    const bandDb = new Float32Array(BANDS);\n    const synth = createSyntheticSource();\n    const writeSample = (v: number) => {\n      ring[ringHead] = v;\n      ringHead = (ringHead + 1) % FFT_N;\n    };\n    const external =\n      source instanceof AnalyserNode\n        ? (() => {\n            const buf = new Float32Array(source.fftSize);\n            return () => {\n              source.getFloatTimeDomainData(buf);\n              const off = Math.max(0, buf.length - FFT_N);\n              for (let i = 0; i < FFT_N; i++) writeSample(buf[off + i] ?? 0);\n            };\n          })()\n        : typeof source === \"function\"\n          ? (() => {\n              const buf = new Float32Array(FFT_N);\n              return () => {\n                source(buf);\n                for (let i = 0; i < FFT_N; i++) writeSample(buf[i] ?? 0);\n              };\n            })()\n          : null;\n\n    // -- history ring --------------------------------------------------------\n    let cols = 0;\n    let cells = new Uint8Array(0); // ramp index per (col, band)\n    let peakBand = new Uint8Array(0);\n    let peakDb = new Float32Array(0);\n    let head = 0; // oldest column\n\n    let riseBoost = 1;\n\n    const analyse = () => {\n      if (external) external();\n      else synth(hopSamples, writeSample);\n\n      for (let n = 0; n < FFT_N; n++) {\n        re[n] = ring[(ringHead + n) % FFT_N]! * HANN[n]!;\n        im[n] = 0;\n      }\n      fft(re, im);\n\n      for (let k = 0; k < BANDS; k++) {\n        const start = BAND_START[k]!;\n        const end = Math.max(start + 1, BAND_START[k + 1]!);\n        let best = -Infinity;\n        for (let b = start; b < end; b++) {\n          const mag = Math.sqrt(re[b]! * re[b]! + im[b]! * im[b]!) / (FFT_N / 4);\n          const db = 20 * Math.log10(mag + 1e-9);\n          if (db > best) best = db;\n        }\n        bandDb[k] = best;\n        const f = floors[k]!;\n        floors[k] = Math.min(\n          FLOOR_MAX,\n          f + (best - f) * (best < f ? FLOOR_FALL : FLOOR_RISE * riseBoost)\n        );\n      }\n    };\n\n    const commitColumn = () => {\n      if (cols === 0) return;\n      const col = head;\n      head = (head + 1) % cols;\n      const base = col * BANDS;\n      let bestBand = 0;\n      let bestExcess = -Infinity;\n      for (let k = 0; k < BANDS; k++) {\n        const excess = bandDb[k]! - floors[k]!;\n        let idx = 0;\n        if (excess >= GATE_DB) {\n          idx = Math.round((excess / SPAN_DB) * 9);\n          idx = idx < 1 ? 1 : idx > 9 ? 9 : idx;\n        }\n        cells[base + k] = idx;\n        if (excess > bestExcess) {\n          bestExcess = excess;\n          bestBand = k;\n        }\n      }\n      peakBand[col] = bestExcess >= GATE_DB ? bestBand : 255;\n      peakDb[col] = bandDb[bestBand]!;\n    };\n\n    const hop = () => {\n      analyse();\n      commitColumn();\n    };\n\n    // -- layout / tokens -----------------------------------------------------\n    let fg = \"currentColor\";\n    let accent = \"currentColor\";\n    let cellW = cellSize * 0.6;\n    const cellH = cellSize;\n    const rows = BANDS + 1; // row 0 = headroom, rows 1..32 = field (band 31 at top)\n    let sized = false;\n    let disposed = false;\n    let warmed = false;\n\n    const readTokens = () => {\n      fg = getComputedStyle(canvas).color;\n      accent =\n        getComputedStyle(document.documentElement)\n          .getPropertyValue(\"--ns-accent\")\n          .trim() || fg;\n    };\n\n    const bucketLists: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);\n\n    const resize = () => {\n      const { width } = canvas.getBoundingClientRect();\n      const height = rows * cellH;\n      if (width < 2) {\n        sized = false;\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const fontFamily = getComputedStyle(canvas).fontFamily;\n      const off = document.createElement(\"canvas\").getContext(\"2d\");\n      if (off) {\n        off.font = `${cellSize}px ${fontFamily}`;\n        cellW = Math.max(4, off.measureText(\"MMMMMMMMMM\").width / 10);\n      }\n      ctx.font = `${cellSize}px ${fontFamily}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n\n      const nextCols = Math.max(16, Math.floor(width / cellW));\n      if (nextCols !== cols) {\n        cols = nextCols;\n        cells = new Uint8Array(cols * BANDS);\n        peakBand = new Uint8Array(cols).fill(255);\n        peakDb = new Float32Array(cols);\n        head = 0;\n        if (!warmed) {\n          // Settle the per-band floors BEFORE any visible column is committed,\n          // with a boosted rise coefficient so ~300 hops reach where the real\n          // 0.002 coefficient would need ~2000. Without it every band's floor\n          // is still down at its -70 dB seed, everything clears the gate, and\n          // the first visible field is a uniform medium-density wash.\n          riseBoost = WARMUP_BOOST;\n          for (let i = 0; i < WARMUP_HOPS; i++) analyse();\n          riseBoost = 1;\n          warmed = true;\n        }\n        for (let i = 0; i < Math.max(cols, SEED_COLS); i++) hop();\n      }\n      sized = true;\n    };\n\n    // -- cursor state (refs only; nothing on the hot path is React state) -----\n    let cursorCol: number | null = null; // screen column, 0 = oldest/left\n    let cursorAlpha = 0;\n\n    const setReadout = () => {\n      const live = liveRef.current;\n      const cur = cursorTextRef.current;\n      if (live) {\n        // running summary: the loudest band across the whole visible window\n        let bb = 255;\n        let bd = -Infinity;\n        for (let j = 0; j < cols; j++) {\n          const c = (head + j) % cols;\n          if (peakBand[c] === 255) continue;\n          if (peakDb[c]! > bd) {\n            bd = peakDb[c]!;\n            bb = peakBand[c]!;\n          }\n        }\n        live.textContent =\n          bb === 255\n            ? `no signal above floor · ${((cols * HOP_MS) / 1000).toFixed(1)} s window`\n            : `peak ${fmtHz(BAND_START[bb]! * binHz)}  ${bd >= 0 ? \"\" : \"−\"}${Math.abs(bd).toFixed(0)} dB  · ${((cols * HOP_MS) / 1000).toFixed(1)} s window`;\n      }\n      if (cur) {\n        if (cursorCol === null) cur.textContent = \"\";\n        else {\n          const c = (head + cursorCol) % cols;\n          const age = ((cols - 1 - cursorCol) * HOP_MS) / 1000;\n          const b = peakBand[c];\n          cur.textContent =\n            b === undefined || b === 255\n              ? `— below floor  ${age.toFixed(1)} s ago`\n              : `${fmtHz(BAND_START[b]! * binHz)}  ${peakDb[c]! >= 0 ? \"\" : \"−\"}${Math.abs(peakDb[c]!).toFixed(0)} dB  ${age.toFixed(1)} s ago`;\n        }\n      }\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      const w = cols * cellW;\n      ctx.clearRect(0, 0, w + cellW, rows * cellH);\n\n      // field — pass one buckets, pass two draws one alpha per bucket\n      for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;\n      for (let j = 0; j < cols; j++) {\n        const c = (head + j) % cols;\n        const base = c * BANDS;\n        for (let k = 0; k < BANDS; k++) {\n          const idx = cells[base + k]!;\n          if (idx === 0) continue;\n          const bucket = Math.min(\n            ALPHA_BUCKETS - 1,\n            Math.floor(((idx - 1) / 9) * ALPHA_BUCKETS)\n          );\n          bucketLists[bucket]!.push((j << 8) | (k << 2) | 0);\n        }\n      }\n      ctx.fillStyle = fg;\n      for (let b = 0; b < ALPHA_BUCKETS; b++) {\n        const list = bucketLists[b]!;\n        ctx.globalAlpha = 0.22 + (b / (ALPHA_BUCKETS - 1)) * 0.78;\n        for (let n = 0; n < list.length; n++) {\n          const packed = list[n]!;\n          const j = packed >> 8;\n          const k = (packed >> 2) & 63;\n          const c = (head + j) % cols;\n          ctx.fillText(\n            RAMP[cells[c * BANDS + k]!]!,\n            j * cellW + cellW / 2,\n            (1 + (BANDS - 1 - k)) * cellH + cellH * 0.5\n          );\n        }\n      }\n\n      // pitch track — the argmax band of each column, one accent dot drawn ON\n      // its own row (a one-row strip cannot encode WHICH band won, so it\n      // degenerates into a solid rule). Drawn after the field so it reads as a\n      // contour riding over the dominant ribbon.\n      ctx.fillStyle = accent;\n      ctx.globalAlpha = 0.9;\n      for (let j = 0; j < cols; j++) {\n        const c = (head + j) % cols;\n        const b = peakBand[c];\n        if (b === undefined || b === 255) continue;\n        ctx.fillText(\n          \"·\",\n          j * cellW + cellW / 2,\n          (1 + (BANDS - 1 - b)) * cellH + cellH * 0.5\n        );\n      }\n\n      // cursor — a measurement marker, never a distortion of the field\n      if (cursorCol !== null && cursorAlpha > 0.01) {\n        ctx.fillStyle = accent;\n        ctx.globalAlpha = cursorAlpha * 0.8;\n        const x = cursorCol * cellW + cellW / 2;\n        for (let r = 1; r < rows; r++) ctx.fillText(\"┊\", x, r * cellH + cellH * 0.5);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const applyFade = () => {\n      const cur = cursorTextRef.current;\n      const live = liveRef.current;\n      if (cur) cur.style.opacity = String(cursorAlpha);\n      if (live) live.style.opacity = String(1 - cursorAlpha);\n    };\n\n    // -- loop ----------------------------------------------------------------\n    let raf = 0;\n    let last = 0;\n    let acc = 0;\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;\n      last = now;\n      acc += dt * 1000;\n      let hops = 0;\n      while (acc >= HOP_MS && hops < MAX_HOPS_PER_FRAME) {\n        acc -= HOP_MS;\n        hop();\n        hops++;\n      }\n      if (acc > HOP_MS * MAX_HOPS_PER_FRAME) acc = 0;\n      const target = cursorCol !== null ? 1 : 0;\n      cursorAlpha += (target - cursorAlpha) * Math.min(1, dt / CURSOR_TAU);\n      if (cursorCol === null && cursorAlpha < 0.01) cursorAlpha = 0;\n      if (hops > 0 || cursorAlpha > 0) setReadout();\n      applyFade();\n      draw();\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const colFromClientX = (clientX: number) => {\n      const rect = canvas.getBoundingClientRect();\n      const j = Math.floor((clientX - rect.left) / cellW);\n      return j < 0 ? 0 : j > cols - 1 ? cols - 1 : j;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      cursorCol = colFromClientX(e.clientX);\n    };\n    const onPointerLeave = () => {\n      cursorCol = null;\n    };\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n      e.preventDefault();\n      const step = e.key === \"ArrowLeft\" ? -1 : 1;\n      const from = cursorCol === null ? cols - 1 : cursorCol + step;\n      cursorCol = from < 0 ? 0 : from > cols - 1 ? cols - 1 : from;\n      setReadout();\n      // announce only on a deliberate key step — the 20 Hz running summary is\n      // never put in the live region, which would be a screen-reader firehose\n      const sr = srRef.current;\n      if (sr) sr.textContent = cursorTextRef.current?.textContent ?? \"\";\n      if (reduced) {\n        cursorAlpha = 1;\n        setReadout();\n        applyFade();\n        draw();\n      }\n    };\n    const onBlur = () => {\n      if (cursorCol !== null && document.activeElement !== readoutRef.current) {\n        cursorCol = null;\n        const sr = srRef.current;\n        if (sr) sr.textContent = \"\";\n        if (reduced) {\n          cursorAlpha = 0;\n          setReadout();\n          applyFade();\n          draw();\n        }\n      }\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        setReadout();\n        if (reduced) draw();\n      }, 150);\n    };\n\n    const onVis = () => {\n      // the frame queued before the tab hid is still pending — without this the\n      // resumed loop runs alongside it and the hop rate silently doubles\n      cancelAnimationFrame(raf);\n      if (!document.hidden && !reduced && sized) {\n        last = 0;\n        acc = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const readout = readoutRef.current;\n    readout?.addEventListener(\"keydown\", onKeyDown);\n    readout?.addEventListener(\"blur\", onBlur);\n    window.addEventListener(\"resize\", onResize);\n    document.addEventListener(\"visibilitychange\", onVis);\n    if (!reduced) {\n      canvas.addEventListener(\"pointermove\", onPointerMove);\n      canvas.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      setReadout();\n      applyFade();\n      draw();\n      if (!reduced) raf = requestAnimationFrame(loop);\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      readout?.removeEventListener(\"keydown\", onKeyDown);\n      readout?.removeEventListener(\"blur\", onBlur);\n      window.removeEventListener(\"resize\", onResize);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [source, cellSize]);\n\n  const fieldHeight = (BANDS + 1) * cellSize;\n\n  return (\n    <div className={`ns-sab w-full font-mono ${className}`}>\n      <style>{CSS}</style>\n      <div className=\"flex w-full items-start\">\n        <div\n          ref={gutterRef}\n          aria-hidden=\"true\"\n          className=\"relative shrink-0\"\n          style={{ width: cellSize * 4.2, height: fieldHeight }}\n        >\n          {LABEL_BANDS.map((b) => (\n            <span\n              key={b}\n              data-band={b}\n              className=\"absolute right-0 flex items-center justify-end pr-2 text-ns-muted\"\n              style={{\n                top: (1 + (BANDS - 1 - b)) * cellSize,\n                height: cellSize,\n                fontSize: Math.max(9, cellSize * 0.62),\n                lineHeight: 1,\n              }}\n            />\n          ))}\n        </div>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"block min-w-0 flex-1 text-foreground\"\n          style={{ height: fieldHeight }}\n        />\n      </div>\n      <div\n        ref={readoutRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-label={label}\n        className=\"ns-sab-readout relative mt-2 block w-full rounded-sm border border-border px-3 outline-none\"\n        style={{ height: cellSize * 1.9, fontSize: Math.max(10, cellSize * 0.68) }}\n      >\n        <span\n          ref={liveRef}\n          className=\"absolute inset-0 flex items-center px-3 text-ns-muted\"\n        />\n        <span\n          ref={cursorTextRef}\n          className=\"absolute inset-0 flex items-center px-3 text-foreground\"\n          style={{ opacity: 0 }}\n        />\n        <span ref={srRef} aria-live=\"polite\" className=\"ns-sab-sr\" />\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-sab-readout:focus { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n.ns-sab-readout:hover { border-color: var(--ns-accent); }\n.ns-sab-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/spectrogram-ascii-bands.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "spectrogram",
      "audio",
      "fft",
      "ascii",
      "canvas",
      "signal",
      "data-visualization",
      "monochrome"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}