{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-spectrogram",
  "type": "registry:ui",
  "title": "Spectrogram",
  "description": "A time × frequency heatmap. Realtime scrolls an analyser; static analyses PCM; prepared renders bounded host-produced magnitudes at explicit timestamps without analyzing PCM in the face. Log frequency axis, six colour ramps, and the bin-to-pixel mapping is a pure exported function you can test or render headlessly.",
  "dependencies": [
    "@webgpu/types",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/spectrogram.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/spectrogram\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * Spectrogram — a time × frequency heatmap, realtime or static.\n *\n * Three modes share one component and one set of pure cores (see `mode`):\n *\n *   - **realtime** (default): where `Spectrum` shows one instant of the FFT,\n *     this keeps the history — each frame paints a single one-pixel column at\n *     the right edge and shifts the whole image one pixel left, so the display\n *     reads right-to-left as \"now\" back to a few seconds ago.\n *   - **static**: given a finished buffer (`samples`), the entire signal is\n *     analysed with an offline STFT (`lib/stft`) up front and the complete\n *     picture is painted in one pass, time running left→right. No analyser and\n *     no animation loop — the right tool for a whole clip or an offline render.\n *   - **prepared**: a host supplies bounded magnitudes and exact window-center\n *     timestamps. Rendering never analyzes PCM, and shares a timeline's time\n *     domain and logarithmic MIDI-row bounds.\n *\n * Frequency runs up the vertical axis, magnitude is colour, in both modes.\n *\n * The scroll is done by blitting the canvas onto itself at a one-pixel offset\n * rather than by keeping a ring buffer of frames and repainting all of them.\n * That is O(1) per frame regardless of how much history is on screen, which is\n * what makes a full-width spectrogram affordable at animation rate — and it is\n * the trick the Vue original used too. The cost is that history lives only in\n * the pixels: a resize necessarily loses it (see `useAudioCanvas`, which\n * re-sizes the backing store on layout changes).\n *\n * Two pure, canvas-free cores are exported: `sampleSpectrogramColumn`, which\n * turns one FFT frame into the column of normalised magnitudes to paint, and\n * the `SPECTROGRAM_COLOR_SCALES` maps that turn a magnitude into RGB. Both are\n * unit-testable in Node and reusable by a WebGL or offline renderer.\n *\n * Ported from the osc1 Vue `Spectrogram.vue` / `SpectrogramPanel.vue` /\n * `FrequencyBandsOverTime.vue` trio. Deliberate deviations, matching the\n * policy the Lissajous port set:\n *\n *   - No FPS-based auto-degradation. `FrequencyBandsOverTime.vue` throttled\n *     itself to 30 fps by silently skipping frames; a component must not\n *     quietly rewrite the behaviour its caller asked for. Feed it a slower\n *     source, or pause it, if you want fewer frames.\n *   - No analyser construction or connection. The Vue component created its\n *     own `AnalyserNode` and connected the caller's node into it. Here the\n *     analyser comes in through the shared `use-audio-source` contract and is\n *     only ever read — the same bargain every other display in the kit makes.\n *   - The dB→unit normalisation is explicit (`minDecibels`/`maxDecibels`)\n *     rather than riding on `getByteFrequencyData`'s hidden 0..255 mapping, so\n *     the same component works from a `getData` callback with no analyser.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useCallback, useEffect, useRef } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport {\n  COLOR_SCALES,\n  type ColorScale,\n  type ColorScaleName,\n  type RGB,\n} from \"maruaudio/ui/shared/color-scale\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  useAudioCanvas,\n  type AudioSourceProps,\n} from \"maruaudio/ui/shared/use-audio-source\"\nimport {\n  computeSpectrogram,\n  toMono,\n} from \"maruaudio/ui/shared/stft\"\nimport type { StftSpectrogram } from \"maruaudio/ui/shared/stft\"\nimport {\n  preparedSpectrogramToRaster,\n  type PreparedSpectrogramData,\n} from \"maruaudio/ui/shared/prepared-spectrogram\"\nexport type { PreparedSpectrogramData } from \"maruaudio/ui/shared/prepared-spectrogram\"\n\n/** RGB in 0..255, the form a canvas fill style is built from. */\nexport type SpectrogramRGB = RGB\n\n/** Maps a 0..1 magnitude to a colour. */\nexport type SpectrogramColorScale = ColorScale\n\nexport type SpectrogramColorScaleName = ColorScaleName\n\nconst clampUnit = (value: number): number =>\n  value < 0 ? 0 : value > 1 ? 1 : value\n\n/**\n * The colour ramps: the kit's shared `COLOR_SCALES` (`lib/color-scale`), the\n * real multi-stop scientific colormaps from `d3-scale-chromatic` tabulated\n * once. Re-exported under the spectrogram's historical name so existing\n * callers and the WebGL renderer keep the identical ramp the canvas uses.\n */\nexport const SPECTROGRAM_COLOR_SCALES: Record<\n  SpectrogramColorScaleName,\n  SpectrogramColorScale\n> = COLOR_SCALES\n\n/**\n * Frequency in Hz to an FFT bin index, clamped into the buffer.\n *\n * Pure. `binCount` bins span 0..nyquist linearly, which is what an FFT gives\n * you regardless of how the display chooses to lay them out.\n */\nexport function frequencyToBin(\n  frequency: number,\n  sampleRate: number,\n  binCount: number\n): number {\n  if (binCount <= 0) return 0\n  const nyquist = sampleRate / 2\n  const index = Math.floor((frequency / nyquist) * binCount)\n  return Math.max(0, Math.min(binCount - 1, index))\n}\n\nexport interface SpectrogramColumnParams {\n  /** How many pixel rows the column has. */\n  rows: number\n  /** Sample rate of the analysed signal, for the frequency axis. */\n  sampleRate: number\n  /** Lowest frequency at the bottom of the column, in Hz. */\n  minFrequency?: number\n  /** Highest frequency at the top of the column, in Hz. */\n  maxFrequency?: number\n  /** Bottom of the magnitude range, in dB. */\n  minDecibels?: number\n  /** Top of the magnitude range, in dB. */\n  maxDecibels?: number\n  /** Multiplier applied after normalisation, before clamping. */\n  gain?: number\n  /** Peak preserves narrow partials; mean power estimates energy per Hz. */\n  reduction?: \"peak\" | \"mean-power\"\n  /**\n   * Frequency axis mapping. Log spreads the musically dense low end over\n   * usable height; linear is what the raw bins give you.\n   */\n  scale?: \"log\" | \"linear\"\n}\n\n/**\n * One frame of dB magnitudes → one column of 0..1 values, bottom row first.\n *\n * Pure: no canvas, no DOM, no React. This is the whole of the spectrogram's\n * data path — everything the component adds on top is colour and blitting —\n * so the mapping from bins to pixels can be asserted on directly.\n *\n * Index 0 is the **bottom** row (lowest frequency). The renderer flips it when\n * it paints, because canvas y grows downward while frequency should grow up.\n */\nexport function sampleSpectrogramColumn(\n  data: Float32Array,\n  {\n    rows,\n    sampleRate,\n    minFrequency = 20,\n    maxFrequency,\n    minDecibels = -90,\n    maxDecibels = -10,\n    gain = 1,\n    scale = \"log\",\n    reduction = \"peak\",\n  }: SpectrogramColumnParams\n): Float32Array {\n  const rowCount = Math.max(0, Math.floor(rows))\n  const column = new Float32Array(rowCount)\n  if (rowCount === 0 || data.length === 0) return column\n\n  const nyquist = sampleRate / 2\n  const top = Math.min(maxFrequency ?? nyquist, nyquist)\n  // A degenerate range would divide by zero below; a flat column is the honest\n  // answer for \"no frequencies requested\".\n  const low = Math.max(1, minFrequency)\n  if (top <= low) return column\n\n  const range = maxDecibels - minDecibels\n  const logLow = Math.log10(low)\n  const logTop = Math.log10(top)\n\n  // An FFT's bins are evenly spaced in Hz, but a log display axis is not, so\n  // the two disagree at both ends and a single bin per row is wrong in\n  // opposite ways:\n  //\n  //   - Low end, rows narrower than a bin. At 48 kHz / 2048 a bin is ~23 Hz,\n  //     so 20–100 Hz is about four bins spread over a quarter of the height.\n  //     Point sampling snaps whole runs of rows onto the same bin and paints\n  //     them identically — the flat horizontal slabs in the bass.\n  //   - High end, rows wider than a bin. 10–20 kHz is hundreds of bins folded\n  //     into a few dozen rows, so point sampling reads one bin per row and\n  //     silently discards the rest — a hi-hat's energy thins to a sparse line\n  //     because the bins carrying it were never visited.\n  //\n  // Both are fixed by giving each row the frequency *band* it actually covers\n  // and reducing over it: interpolate when the band is narrower than a bin,\n  // take the peak when it is wider. Peak rather than mean because a\n  // spectrogram is read for partials and transients, and averaging buries a\n  // narrow one in its quiet neighbours.\n  const binWidth = nyquist / Math.max(1, data.length - 1)\n  /** Fractional bin index of a frequency — the un-rounded `frequencyToBin`. */\n  const exactBin = (frequency: number): number => frequency / binWidth\n  /** Lower edge of row `i`, in Hz, for i in 0..rowCount (edges, not centres). */\n  const rowEdge = (i: number): number => {\n    const t = i / rowCount\n    return scale === \"linear\"\n      ? low + (top - low) * t\n      : 10 ** (logLow + (logTop - logLow) * t)\n  }\n\n  const lastBin = data.length - 1\n  /** dB at a fractional bin, linearly interpolated between its neighbours. */\n  const dbAt = (position: number): number => {\n    const clamped = position < 0 ? 0 : position > lastBin ? lastBin : position\n    const lower = Math.floor(clamped)\n    const upper = lower >= lastBin ? lastBin : lower + 1\n    const a = data[lower]\n    const b = data[upper]\n    // -Infinity is what an analyser reports for silence. Interpolating it\n    // yields NaN, so fall back to whichever neighbour is finite.\n    if (!Number.isFinite(a)) return Number.isFinite(b) ? b : -Infinity\n    if (!Number.isFinite(b)) return a\n    const fraction = clamped - lower\n    return a + (b - a) * fraction\n  }\n\n  for (let row = 0; row < rowCount; row += 1) {\n    const bandLow = exactBin(rowEdge(row))\n    const bandHigh = exactBin(rowEdge(row + 1))\n\n    let db: number\n    const firstWhole = Math.ceil(bandLow)\n    const lastWhole = Math.floor(bandHigh)\n    if (lastWhole < firstWhole) {\n      // The row falls strictly between two bin centres: no bin to peak over,\n      // so read the interpolated value at the band's centre. This is what\n      // replaces the bass banding with a smooth ramp.\n      db = dbAt((bandLow + bandHigh) / 2)\n    } else if (reduction === \"mean-power\") {\n      // Average linear power, then return to dB. A wider high band must not\n      // gain level merely because it contains more FFT bins.\n      const from = Math.max(0, firstWhole)\n      const to = Math.min(lastBin, lastWhole)\n      let power = 0\n      let count = 0\n      for (let bin = from; bin <= to; bin += 1) {\n        const value = data[bin]\n        if (Number.isFinite(value)) {\n          power += 10 ** (value / 10)\n          count += 1\n        }\n      }\n      db = count > 0 && power > 0 ? 10 * Math.log10(power / count) : -Infinity\n    } else {\n      // At least one whole bin lands inside. Take the loudest of them, and of\n      // the interpolated band edges so a peak just outside the whole-bin span\n      // is not lost.\n      db = Math.max(dbAt(bandLow), dbAt(bandHigh))\n      const from = Math.max(0, firstWhole)\n      const to = Math.min(lastBin, lastWhole)\n      for (let bin = from; bin <= to; bin += 1) {\n        const value = data[bin]\n        if (value > db) db = value\n      }\n    }\n\n    const unit =\n      Number.isFinite(db) && range !== 0 ? (db - minDecibels) / range : 0\n    column[row] = clampUnit(unit * gain)\n  }\n\n  return column\n}\n\n/**\n * How the display is driven.\n *\n *   - `\"realtime\"` (default): a live analyser or `getData` callback feeds one\n *     FFT frame per animation tick, and the image scrolls right-to-left so it\n *     reads \"now\" back to a few seconds ago. This is the historical behaviour.\n *   - `\"static\"`: a finished buffer (`samples`) is analysed in full up front\n *     and the whole time × frequency picture is painted once — no analyser, no\n *     animation loop. Time runs left→right across the width. Use this to show\n *     an entire clip, an offline render, or a decoded file.\n *   - `\"prepared\"`: already analyzed display data with explicit timestamps,\n *     suitable for isolated module faces and exact time/pitch overlays.\n */\nexport type SpectrogramMode = \"realtime\" | \"static\" | \"prepared\"\n\n/** A mono `Float32Array`, or an `AudioBuffer` (down-mixed to mono internally). */\nexport type SpectrogramSamples = Float32Array | AudioBuffer\n\n/**\n * A lightweight original-versus-modified overlay for a static spectrogram.\n *\n * The selected source-time range stays in the same horizontal coordinates as\n * the original. `frequencyScale` moves the selected spectral image vertically\n * to preview playback-rate pitch changes without resampling audio or touching\n * the realtime engine. The expensive STFT raster is cached independently from\n * these values, so dragging a trim guide only recomposites existing pixels.\n */\nexport interface StaticSpectrogramComparison {\n  /** Normalized source-time position of the retained range start. */\n  start: number\n  /** Normalized source-time position of the retained range end. */\n  end: number\n  /** Frequency multiplier for the modified layer. Default 1. */\n  frequencyScale?: number\n  /** Opacity of the complete original raster. Default 0.34. */\n  originalOpacity?: number\n  /** Opacity of the selected modified layer. Default 1. */\n  modifiedOpacity?: number\n}\n\nexport interface SpectrogramProps extends AudioSourceProps {\n  /** Realtime analyser, static PCM analysis, or prepared display data. Default \"realtime\". */\n  mode?: SpectrogramMode\n  /** Prepared mode: bounded host/worker-produced spectral data; no PCM analysis in the face. */\n  prepared?: PreparedSpectrogramData\n  /** Prepared mode: timeline domain; defaults to the source's duration. */\n  durationSeconds?: number\n  /**\n   * Static mode only: the audio to analyse. A mono `Float32Array` or an\n   * `AudioBuffer` (down-mixed to mono). Ignored in realtime mode.\n   */\n  samples?: SpectrogramSamples\n  /**\n   * Static mode only: FFT / analysis window size in samples (rounded up to a\n   * power of two). Larger means finer frequency, coarser time. Default 2048.\n   */\n  fftSize?: number\n  /** Static mode only: hop between analysis frames, in samples. Default fftSize/4. */\n  hop?: number\n  width?: number | string\n  height?: number | string\n  /** Colour ramp name, or your own magnitude → RGB function. */\n  colorScale?: SpectrogramColorScaleName | SpectrogramColorScale\n  /** Lowest frequency shown, in Hz. */\n  minFrequency?: number\n  /** Highest frequency shown, in Hz. Defaults to the Nyquist limit. */\n  maxFrequency?: number\n  /** Bottom of the magnitude range, in dB. */\n  minDecibels?: number\n  /** Top of the magnitude range, in dB. */\n  maxDecibels?: number\n  /** Magnitude multiplier applied after normalisation. */\n  gain?: number\n  /** Frequency axis mapping. */\n  scale?: \"log\" | \"linear\"\n  /** Realtime mode only: pixels the image scrolls per frame. Higher is faster, coarser history. */\n  scrollSpeed?: number\n  /**\n   * Sample rate for the frequency axis. Required in static mode when `samples`\n   * is a bare `Float32Array` (an `AudioBuffer` carries its own); in realtime\n   * mode it defaults from the analyser, and is only needed with `getData`.\n   */\n  sampleRate?: number\n  /**\n   * Draw the frequency axis labels down the left edge.\n   *\n   * In the Vue panel this was effectively fullscreen-only chrome. Here it is an\n   * unconditional prop, on by default: the caller decides, not the layout.\n   */\n  showAxis?: boolean\n  /** Static mode only: compare the full source with a trimmed/pitched layer. */\n  comparison?: StaticSpectrogramComparison\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Decade labels a listener actually reads a frequency axis by. */\nconst AXIS_FREQUENCIES = [100, 1000, 10000]\n\nconst AXIS_RAIL_X = 5.5\nconst AXIS_TICK_END_X = 9.5\nconst AXIS_LABEL_X = 15\nconst AXIS_INK = \"rgba(248, 250, 252, 0.96)\"\nconst AXIS_SHADOW = \"rgba(2, 6, 23, 0.9)\"\n\nconst formatAxisLabel = (frequency: number): string =>\n  frequency >= 1000 ? `${frequency / 1000}k` : `${frequency}`\n\nexport interface SpectrogramFrequencyTransformParams {\n  height: number\n  sampleRate: number\n  minFrequency: number\n  maxFrequency?: number\n  frequencyScale: number\n  scale: \"log\" | \"linear\"\n}\n\n/**\n * Canvas destination geometry for multiplying every visible frequency.\n * Exported as a pure core so the visual transform can be pinned in Node tests.\n */\nexport function spectrogramFrequencyTransform({\n  height,\n  sampleRate,\n  minFrequency,\n  maxFrequency,\n  frequencyScale,\n  scale,\n}: SpectrogramFrequencyTransformParams): { y: number; height: number } {\n  const canvasHeight = Math.max(0, height)\n  const multiplier =\n    Number.isFinite(frequencyScale) && frequencyScale > 0 ? frequencyScale : 1\n  const nyquist = sampleRate / 2\n  const top = Math.min(maxFrequency ?? nyquist, nyquist)\n  const low = Math.max(1, minFrequency)\n  if (canvasHeight === 0 || top <= low || multiplier === 1) {\n    return { y: 0, height: canvasHeight }\n  }\n  if (scale === \"log\") {\n    return {\n      y:\n        (-Math.log(multiplier) / (Math.log(top) - Math.log(low))) *\n        canvasHeight,\n      height: canvasHeight,\n    }\n  }\n  return {\n    y: (canvasHeight * top * (1 - multiplier)) / (top - low),\n    height: canvasHeight * multiplier,\n  }\n}\n\nfunction sameRasterKey(\n  left: readonly unknown[],\n  right: readonly unknown[]\n): boolean {\n  return (\n    left.length === right.length &&\n    left.every((value, index) => Object.is(value, right[index]))\n  )\n}\n\ninterface DrawFrequencyAxisOptions {\n  ctx: CanvasRenderingContext2D\n  width: number\n  height: number\n  sampleRate: number\n  minFrequency: number\n  maxFrequency?: number\n  scale: \"log\" | \"linear\"\n}\n\n/**\n * Draw the frequency scale as a compact rail instead of opaque label plates.\n *\n * Bright outlined text gives the scale stronger contrast than the old muted\n * labels, while the rail and short ticks locate it without covering detail.\n */\nfunction drawFrequencyAxis({\n  ctx,\n  width,\n  height,\n  sampleRate,\n  minFrequency,\n  maxFrequency,\n  scale,\n}: DrawFrequencyAxisOptions): void {\n  const nyquist = sampleRate / 2\n  const top = Math.min(maxFrequency ?? nyquist, nyquist)\n  const low = Math.max(1, minFrequency)\n  if (top <= low || width <= 0 || height <= 0) return\n\n  const ticks: Array<{ label: string; y: number }> = []\n  for (const frequency of AXIS_FREQUENCIES) {\n    if (frequency <= low || frequency >= top) continue\n    const t =\n      scale === \"linear\"\n        ? (frequency - low) / (top - low)\n        : (Math.log10(frequency) - Math.log10(low)) /\n          (Math.log10(top) - Math.log10(low))\n    ticks.push({ label: formatAxisLabel(frequency), y: height - t * height })\n  }\n  if (ticks.length === 0) return\n\n  ctx.save()\n  ctx.font = \"11px ui-monospace, SFMono-Regular, Menlo, monospace\"\n  ctx.textAlign = \"left\"\n  ctx.textBaseline = \"middle\"\n  ctx.lineCap = \"round\"\n  ctx.lineJoin = \"round\"\n\n  const drawRail = (strokeStyle: string, lineWidth: number) => {\n    ctx.strokeStyle = strokeStyle\n    ctx.lineWidth = lineWidth\n    ctx.beginPath()\n    ctx.moveTo(AXIS_RAIL_X, 1)\n    ctx.lineTo(AXIS_RAIL_X, height - 1)\n    for (const { y } of ticks) {\n      const crispY = Math.round(y) + 0.5\n      ctx.moveTo(AXIS_RAIL_X, crispY)\n      ctx.lineTo(AXIS_TICK_END_X, crispY)\n    }\n    ctx.stroke()\n  }\n\n  drawRail(AXIS_SHADOW, 3)\n  drawRail(AXIS_INK, 1)\n\n  ctx.strokeStyle = AXIS_INK\n  ctx.lineWidth = 1.25\n  ctx.shadowColor = AXIS_SHADOW\n  ctx.shadowBlur = 2\n  for (const { label, y } of ticks) {\n    ctx.strokeText(label, AXIS_LABEL_X, y)\n  }\n  ctx.restore()\n}\n\n/**\n * The historical scrolling spectrogram: one analyser/`getData` frame per\n * animation tick, blitted right-to-left. Behaviour is unchanged from before\n * the `mode` prop existed — the dispatcher below routes realtime callers here.\n */\nfunction RealtimeSpectrogram({\n  analyser,\n  getData,\n  dataSize = 1024,\n  paused = false,\n  width = \"100%\",\n  height = 200,\n  colorScale = \"viridis\",\n  minFrequency = 20,\n  maxFrequency,\n  minDecibels = -90,\n  maxDecibels = -10,\n  gain = 1,\n  scale = \"log\",\n  scrollSpeed = 1,\n  sampleRate,\n  showAxis = true,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: SpectrogramProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n\n  const draw = useCallback(\n    ({\n      ctx,\n      width: w,\n      height: h,\n      data,\n    }: {\n      ctx: CanvasRenderingContext2D\n      width: number\n      height: number\n      data: Float32Array | null\n    }) => {\n      const styles = getComputedStyle(ctx.canvas)\n      const surface =\n        styles.getPropertyValue(\"--uikit-surface\").trim() || \"#14161d\"\n\n      const mapper =\n        typeof colorScale === \"function\"\n          ? colorScale\n          : (SPECTROGRAM_COLOR_SCALES[colorScale] ??\n            SPECTROGRAM_COLOR_SCALES.viridis)\n\n      const rate = sampleRate ?? analyser?.context.sampleRate ?? 48000\n\n      /**\n       * Axis labels, repainted every frame because the scroll blit drags the\n       * previous frame's labels leftward with the image.\n       */\n      const drawAxis = () => {\n        drawFrequencyAxis({\n          ctx,\n          width: w,\n          height: h,\n          sampleRate: rate,\n          minFrequency,\n          maxFrequency,\n          scale,\n        })\n      }\n\n      // Resting state: no source attached yet, or paused before the first\n      // frame. Paint the surface so the component is a display rather than a\n      // transparent hole, and stop.\n      if (!data || data.length === 0) {\n        ctx.fillStyle = surface\n        ctx.fillRect(0, 0, w, h)\n        if (showAxis) drawAxis()\n        return\n      }\n\n      const step = Math.max(1, Math.round(scrollSpeed))\n\n      // Scroll: blit the canvas onto itself shifted left by `step`. Copying a\n      // canvas onto itself is well-defined (the source is snapshotted first),\n      // so this needs no scratch canvas — the Vue original kept one only\n      // because it also wanted a separate compositing target.\n      //\n      // The source rect is in the backing store's intrinsic pixels — it does\n      // NOT pass through the current transform; only the destination rect\n      // does. So the source must be the full device-pixel store while the\n      // destination is the full CSS-pixel rect (shifted): mixing the two\n      // frames of reference rescales the image by devicePixelRatio on every\n      // blit, which on a HiDPI screen zooms the whole history exponentially.\n      ctx.drawImage(\n        ctx.canvas,\n        0,\n        0,\n        ctx.canvas.width,\n        ctx.canvas.height,\n        -step,\n        0,\n        w,\n        h\n      )\n\n      // One value per device-independent pixel row. Sub-pixel rows would be\n      // averaged away by the canvas anyway.\n      const rows = Math.max(1, Math.round(h))\n      const column = sampleSpectrogramColumn(data, {\n        rows,\n        sampleRate: rate,\n        minFrequency,\n        maxFrequency,\n        minDecibels,\n        maxDecibels,\n        gain,\n        scale,\n      })\n\n      const rowHeight = h / rows\n      for (let row = 0; row < rows; row += 1) {\n        const [r, g, b] = mapper(column[row])\n        ctx.fillStyle = `rgb(${r}, ${g}, ${b})`\n        // Column index 0 is the lowest frequency, which belongs at the bottom.\n        const y = h - (row + 1) * rowHeight\n        // Overdraw by a hair so rounding never leaves hairline gaps between\n        // adjacent rows on fractional-pixel heights.\n        ctx.fillRect(w - step, y, step, rowHeight + 1)\n      }\n\n      if (showAxis) drawAxis()\n    },\n    [\n      colorScale,\n      minFrequency,\n      maxFrequency,\n      minDecibels,\n      maxDecibels,\n      gain,\n      scale,\n      scrollSpeed,\n      sampleRate,\n      showAxis,\n      analyser,\n    ]\n  )\n\n  useAudioCanvas(canvasRef, {\n    analyser,\n    getData,\n    dataSize,\n    paused,\n    kind: \"frequency\",\n    draw,\n  })\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? \"Spectrogram\"}\n      style={{\n        display: \"block\",\n        width,\n        height,\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        ...style,\n      }}\n    />\n  )\n}\n\n/**\n * Hop, in samples, that gives a static render roughly one analysis frame per\n * output pixel column.\n *\n * The STFT default is `fftSize / 4` — about 10.7 ms at 48 kHz / 2048. For a\n * whole track that is far finer than the display can show, but a 150 ms drum\n * hit is then only ~14 frames, and stretching 14 frames across a 1200-pixel\n * canvas paints each one ~85 pixels wide: the squarish steps a short sound\n * shows. Choosing the hop from the output width instead keeps the time\n * resolution matched to what is actually on screen.\n *\n * Bounded at both ends: never finer than `minHop` (a floor on analysis cost\n * for a long buffer at a wide canvas), and never coarser than the STFT default\n * (so this only ever adds detail, never removes it).\n *\n * Pure — no canvas, no DOM.\n */\nexport function adaptiveStaticHop({\n  sampleCount,\n  width,\n  fftSize,\n  minHop = 32,\n}: {\n  sampleCount: number\n  width: number\n  fftSize: number\n  minHop?: number\n}): number {\n  const coarsest = Math.max(1, Math.floor(fftSize / 4))\n  if (!(sampleCount > 0) || !(width > 0)) return coarsest\n  const perPixel = Math.floor(sampleCount / width)\n  const floor = Math.max(1, Math.floor(minHop))\n  if (perPixel < floor) return floor\n  return Math.min(coarsest, perPixel)\n}\n\n/** Mono samples + sample rate out of whatever static-mode input was given. */\nconst audioBufferMonoCache = new WeakMap<\n  AudioBuffer,\n  { mono: Float32Array; rate: number }\n>()\nconst staticAnalysisCache = new WeakMap<object, Map<string, StftSpectrogram>>()\n\nfunction resolveSamples(\n  samples: SpectrogramSamples | undefined,\n  sampleRate: number | undefined\n): { mono: Float32Array; rate: number } | null {\n  if (!samples) return null\n  // An AudioBuffer carries its own rate and channels; a bare Float32Array is\n  // already mono and needs the rate supplied.\n  if (samples instanceof Float32Array) {\n    return { mono: samples, rate: sampleRate ?? 48000 }\n  }\n  const cached = audioBufferMonoCache.get(samples)\n  const rate = sampleRate ?? samples.sampleRate\n  if (cached && cached.rate === rate) return cached\n  const channels: Float32Array[] = []\n  for (let c = 0; c < samples.numberOfChannels; c += 1) {\n    const data = new Float32Array(samples.length)\n    samples.copyFromChannel(data, c)\n    channels.push(data)\n  }\n  const resolved = { mono: toMono(channels), rate }\n  audioBufferMonoCache.set(samples, resolved)\n  return resolved\n}\n\nfunction cachedStaticAnalysis(\n  source: SpectrogramSamples,\n  mono: Float32Array,\n  sampleRate: number,\n  fftSize: number,\n  hop: number | undefined\n): StftSpectrogram {\n  let analyses = staticAnalysisCache.get(source)\n  if (!analyses) {\n    analyses = new Map()\n    staticAnalysisCache.set(source, analyses)\n  }\n  const key = `${sampleRate}:${fftSize}:${hop ?? \"auto\"}`\n  const cached = analyses.get(key)\n  if (cached) return cached\n  const analysis = computeSpectrogram(mono, { fftSize, hop, sampleRate })\n  analyses.set(key, analysis)\n  return analysis\n}\n\n/**\n * The whole-buffer spectrogram: the entire signal is analysed once and the\n * complete image painted in a single pass, with time running left→right. No\n * analyser, no animation loop — a `paused`/hidden tab does not matter because\n * there is nothing to animate. Re-renders only when the samples, geometry, or\n * mapping actually change.\n */\nfunction StaticSpectrogram({\n  samples,\n  fftSize = 2048,\n  hop,\n  width = \"100%\",\n  height = 200,\n  colorScale = \"viridis\",\n  minFrequency = 20,\n  maxFrequency,\n  minDecibels = -90,\n  maxDecibels = -10,\n  gain = 1,\n  scale = \"log\",\n  sampleRate,\n  showAxis = true,\n  comparison,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: SpectrogramProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  const rasterRef = useRef<{\n    key: readonly unknown[]\n    canvas: HTMLCanvasElement\n  } | null>(null)\n\n  const comparisonStart = clampUnit(comparison?.start ?? 0)\n  const comparisonEnd = clampUnit(comparison?.end ?? 1)\n  const comparisonFrequencyScale = comparison?.frequencyScale ?? 1\n  const originalOpacity = clampUnit(comparison?.originalOpacity ?? 0.34)\n  const modifiedOpacity = clampUnit(comparison?.modifiedOpacity ?? 1)\n  const hasComparison = comparison !== undefined\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    const mapper =\n      typeof colorScale === \"function\"\n        ? colorScale\n        : (SPECTROGRAM_COLOR_SCALES[colorScale] ??\n          SPECTROGRAM_COLOR_SCALES.viridis)\n\n    const styles = getComputedStyle(canvas)\n    const surface =\n      styles.getPropertyValue(\"--uikit-surface\").trim() || \"#14161d\"\n\n    // Size the backing store to the laid-out CSS box × DPR. A zero-sized box\n    // means the element is not laid out yet; the observer below repaints once\n    // it is.\n    const paint = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      const rect = canvas.getBoundingClientRect()\n      if (rect.width < 1 || rect.height < 1) return\n      const w = Math.max(1, Math.floor(rect.width * dpr))\n      const h = Math.max(1, Math.floor(rect.height * dpr))\n      if (canvas.width !== w || canvas.height !== h) {\n        canvas.width = w\n        canvas.height = h\n      }\n\n      const resolved = resolveSamples(samples, sampleRate)\n\n      // No audio yet (still loading, or an empty pad): paint the resting\n      // surface so the component is a display, not a transparent hole.\n      if (!resolved || resolved.mono.length === 0) {\n        rasterRef.current = null\n        ctx.setTransform(1, 0, 0, 1, 0, 0)\n        ctx.fillStyle = surface\n        ctx.fillRect(0, 0, w, h)\n        return\n      }\n\n      // An explicit `hop` is the caller's decision and is honoured as given.\n      // Left to us, it follows the canvas width so a short sound is analysed\n      // finely enough to fill it — see `adaptiveStaticHop`.\n      const effectiveHop =\n        hop ??\n        adaptiveStaticHop({\n          sampleCount: resolved.mono.length,\n          width: w,\n          fftSize,\n        })\n\n      const rasterKey: readonly unknown[] = [\n        samples,\n        resolved.rate,\n        fftSize,\n        effectiveHop,\n        w,\n        h,\n        colorScale,\n        minFrequency,\n        maxFrequency,\n        minDecibels,\n        maxDecibels,\n        gain,\n        scale,\n      ]\n      let sourceRaster = rasterRef.current\n      if (!sourceRaster || !sameRasterKey(sourceRaster.key, rasterKey)) {\n        const spectrogram = cachedStaticAnalysis(\n          samples as SpectrogramSamples,\n          resolved.mono,\n          resolved.rate,\n          fftSize,\n          effectiveHop\n        )\n\n        // Build the RGBA image at device-pixel resolution: one normalised\n        // column per source frame, reduced onto the output width.\n        const image = ctx.createImageData(w, h)\n        const pixels = image.data\n        const columns: Float32Array[] = new Array(spectrogram.frames)\n        for (let f = 0; f < spectrogram.frames; f += 1) {\n          const frameDb = spectrogram.db.subarray(\n            f * spectrogram.bins,\n            f * spectrogram.bins + spectrogram.bins\n          )\n          columns[f] = sampleSpectrogramColumn(frameDb, {\n            rows: h,\n            sampleRate: resolved.rate,\n            minFrequency,\n            maxFrequency,\n            minDecibels,\n            maxDecibels,\n            gain,\n            scale,\n          })\n        }\n        // Frames onto pixel columns, by peak — never by blending.\n        //\n        // The adaptive hop above targets one frame per pixel, but a long\n        // buffer still clamps to the coarsest hop and lands several frames in\n        // every column. Point-sampling one of them would discard the rest,\n        // which is the same detail loss the row sampler was fixed for; taking\n        // the loudest keeps every transient that occurred inside the column.\n        //\n        // Interpolating between neighbouring frames was tried and removed: it\n        // is a low-pass along the time axis, and it smears genuinely distinct\n        // frames together now that there is roughly one per pixel. A hard\n        // frame boundary is real data; a blended one is invented.\n        const lastFrame = spectrogram.frames - 1\n        const framesPerPixel = spectrogram.frames / w\n        for (let x = 0; x < w; x += 1) {\n          const from = Math.min(lastFrame, Math.floor(x * framesPerPixel))\n          // At least one frame per column, and never past the last.\n          const to = Math.max(\n            from,\n            Math.min(lastFrame, Math.ceil((x + 1) * framesPerPixel) - 1)\n          )\n          const first = columns[from]\n          for (let row = 0; row < h; row += 1) {\n            let value = first[row]\n            for (let f = from + 1; f <= to; f += 1) {\n              const candidate = columns[f][row]\n              if (candidate > value) value = candidate\n            }\n            const [r, g, b] = mapper(value)\n            // Row 0 is the lowest frequency, which belongs at the bottom.\n            const y = h - 1 - row\n            const offset = (y * w + x) * 4\n            pixels[offset] = r\n            pixels[offset + 1] = g\n            pixels[offset + 2] = b\n            pixels[offset + 3] = 255\n          }\n        }\n        const rasterCanvas = document.createElement(\"canvas\")\n        rasterCanvas.width = w\n        rasterCanvas.height = h\n        rasterCanvas.getContext(\"2d\")?.putImageData(image, 0, 0)\n        sourceRaster = { key: rasterKey, canvas: rasterCanvas }\n        rasterRef.current = sourceRaster\n      }\n\n      ctx.setTransform(1, 0, 0, 1, 0, 0)\n      ctx.globalAlpha = 1\n      ctx.fillStyle = surface\n      ctx.fillRect(0, 0, w, h)\n      if (hasComparison) {\n        ctx.globalAlpha = originalOpacity\n        ctx.drawImage(sourceRaster.canvas, 0, 0)\n\n        const start = Math.min(comparisonStart, comparisonEnd)\n        const end = Math.max(comparisonStart, comparisonEnd)\n        const startX = Math.floor(start * w)\n        const endX = Math.max(startX + 1, Math.ceil(end * w))\n        const transform = spectrogramFrequencyTransform({\n          height: h,\n          sampleRate: resolved.rate,\n          minFrequency,\n          maxFrequency,\n          frequencyScale: comparisonFrequencyScale,\n          scale,\n        })\n        ctx.save()\n        ctx.beginPath()\n        ctx.rect(startX, 0, endX - startX, h)\n        ctx.clip()\n        ctx.globalAlpha = modifiedOpacity\n        ctx.drawImage(\n          sourceRaster.canvas,\n          startX,\n          0,\n          endX - startX,\n          h,\n          startX,\n          transform.y,\n          endX - startX,\n          transform.height\n        )\n        ctx.restore()\n      } else {\n        ctx.drawImage(sourceRaster.canvas, 0, 0)\n      }\n      ctx.globalAlpha = 1\n\n      if (showAxis) {\n        // Axis is drawn in CSS pixels on top of the device-pixel image.\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        drawFrequencyAxis({\n          ctx,\n          width: rect.width,\n          height: rect.height,\n          sampleRate: resolved.rate,\n          minFrequency,\n          maxFrequency,\n          scale,\n        })\n      }\n    }\n\n    paint()\n\n    // The laid-out size is often not final on first paint (a canvas in an\n    // unlaid-out flex parent reports zero width), and the parent may be\n    // revealed later — so repaint on resize of both the canvas and its parent.\n    const observer =\n      typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(paint) : null\n    observer?.observe(canvas)\n    if (canvas.parentElement) observer?.observe(canvas.parentElement)\n    return () => observer?.disconnect()\n  }, [\n    samples,\n    fftSize,\n    hop,\n    colorScale,\n    minFrequency,\n    maxFrequency,\n    minDecibels,\n    maxDecibels,\n    gain,\n    scale,\n    sampleRate,\n    showAxis,\n    hasComparison,\n    comparisonStart,\n    comparisonEnd,\n    comparisonFrequencyScale,\n    originalOpacity,\n    modifiedOpacity,\n  ])\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? \"Spectrogram\"}\n      style={{\n        display: \"block\",\n        width,\n        height,\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        ...style,\n      }}\n    />\n  )\n}\n\n/** Prepared data shares the renderer/color maps, but never enters the PCM analysis path. */\nfunction PreparedSpectrogram({\n  prepared,\n  durationSeconds,\n  width = \"100%\",\n  height = 200,\n  colorScale = \"viridis\",\n  minFrequency = 20,\n  maxFrequency,\n  scale = \"log\",\n  showAxis = true,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: SpectrogramProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  useEffect(() => {\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!canvas || !ctx) return\n    const mapper =\n      typeof colorScale === \"function\"\n        ? colorScale\n        : (SPECTROGRAM_COLOR_SCALES[colorScale] ??\n          SPECTROGRAM_COLOR_SCALES.viridis)\n    const paint = () => {\n      const rect = canvas.getBoundingClientRect()\n      if (rect.width < 1 || rect.height < 1) return\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      const w = Math.max(1, Math.min(4096, Math.round(rect.width * dpr)))\n      const h = Math.max(1, Math.min(2048, Math.round(rect.height * dpr)))\n      canvas.width = w\n      canvas.height = h\n      if (!prepared) {\n        ctx.clearRect(0, 0, w, h)\n        return\n      }\n      const nyquist = (prepared.bins - 1) * prepared.binFrequencyHz\n      const intensities = preparedSpectrogramToRaster(prepared, {\n        width: w,\n        height: h,\n        durationSeconds: durationSeconds ?? prepared.durationSeconds,\n        minFrequency,\n        maxFrequency: maxFrequency ?? nyquist,\n        scale,\n      })\n      const image = ctx.createImageData(w, h)\n      for (let i = 0; i < intensities.length; i += 1) {\n        const [r, g, b] = mapper(intensities[i]! / 255)\n        image.data[i * 4] = r\n        image.data[i * 4 + 1] = g\n        image.data[i * 4 + 2] = b\n        image.data[i * 4 + 3] = 255\n      }\n      ctx.putImageData(image, 0, 0)\n      if (showAxis) {\n        ctx.setTransform(w / rect.width, 0, 0, h / rect.height, 0, 0)\n        drawFrequencyAxis({\n          ctx,\n          width: rect.width,\n          height: rect.height,\n          sampleRate: nyquist * 2,\n          minFrequency,\n          maxFrequency,\n          scale,\n        })\n      }\n    }\n    paint()\n    const observer =\n      typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(paint)\n    observer?.observe(canvas)\n    return () => observer?.disconnect()\n  }, [\n    prepared,\n    durationSeconds,\n    colorScale,\n    minFrequency,\n    maxFrequency,\n    scale,\n    showAxis,\n  ])\n  return (\n    <canvas\n      ref={canvasRef}\n      data-uikit-prepared-spectrogram=\"\"\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? \"Prepared spectrogram\"}\n      style={{\n        display: \"block\",\n        width,\n        height,\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        ...style,\n      }}\n    />\n  )\n}\n\n/** Time × frequency heatmap. Each mode owns its hooks and input boundary. */\nexport function Spectrogram(props: SpectrogramProps) {\n  return props.mode === \"prepared\" ? (\n    <PreparedSpectrogram {...props} />\n  ) : props.mode === \"static\" ? (\n    <StaticSpectrogram {...props} />\n  ) : (\n    <RealtimeSpectrogram {...props} />\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/spectrogram.tsx"
    }
  ]
}
