{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-lfo-visualizer",
  "type": "registry:ui",
  "title": "LFO Visualizer",
  "description": "An LFO's waveform with a cursor riding the current phase. Drawn from the shape's closed form rather than measured off an analyser — which is the only sensible way to display a modulator running well below 20 Hz. Pass a phase to drive it from a real LFO, or let it run on its own clock.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/lfo-visualizer.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/lfo-visualizer\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 * LFO Visualizer — an LFO's waveform, with a cursor at the current phase.\n *\n * Purely parameter-driven: give it a shape, a rate and a range and it draws\n * what the modulator is doing. No audio, no analyser, no AudioContext. That\n * matters because an LFO is usually running well below 20 Hz, where reading it\n * back off an analyser is both awkward and pointless — the shape is known in\n * closed form, so it is drawn from the formula instead of measured.\n *\n * The cursor advances on its own clock at `frequency` Hz, which is what makes\n * the display read as *live* rather than as a static curve. Pass `phase`\n * explicitly to drive it from a real modulator instead and the internal clock\n * stands down — the component becomes a controlled display of someone else's\n * phase.\n *\n * `lfoValue` is the whole waveform core: pure, no canvas, no DOM, no React, no\n * time source. Unit-testable in Node, and reusable by anything that needs the\n * same shapes.\n *\n * Ported from the osc1 Vue `LFOVisualizer.vue`. Deliberate deviations, matching\n * the Lissajous port's policy:\n *\n *   - No Tone.js. The Vue component built a `Tone.LFO` purely to read a value\n *     off it and, failing that, reached into the synth engine's private\n *     `_getLFONodes()` to borrow a node. Both are the app's business, not a\n *     display's: the shape is computed from the props here.\n *   - No `enabled` gate and no dark-mode MutationObserver. `paused` is the\n *     standard off switch, and colours come from CSS custom properties, which\n *     re-resolve on a theme change without anything watching the DOM.\n *   - The Vue version drew its waveform from `props.type` but its cursor value\n *     from a *separate* phase formula, so on square and saw the dot could sit\n *     off the curve. Here both read the one `lfoValue`, so the cursor is\n *     always on the line it is tracing.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useEffect, useRef } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  waveformValue,\n  type PeriodicWaveform,\n} from \"maruaudio/ui/shared/waveforms\"\n\n/** The shapes an LFO can take. Aliases match the Web Audio / Tone spelling. */\nexport type LFOWaveform = PeriodicWaveform\n\n/**\n * One cycle of an LFO shape, as a bipolar -1..1 value.\n *\n * Pure: no canvas, no DOM, no React, no clock. `phase` is a position within\n * the cycle in turns — 0 is the start, 1 the end. Values outside 0..1 wrap, so\n * a caller can pass an ever-increasing phase without pre-folding it, and\n * negatives wrap the same way rather than mirroring.\n *\n * Screen mapping is left entirely to the caller, so the same core can drive a\n * canvas, an SVG path, or a test assertion.\n */\nexport function lfoValue(phase: number, waveform: LFOWaveform = \"sine\"): number {\n  return waveformValue(phase, waveform)\n}\n\n/**\n * `lfoValue` mapped onto an output range.\n *\n * Also pure. An LFO is rarely wired to a bipolar destination — a filter cutoff\n * or a tremolo depth wants an actual parameter range — so the display shows\n * the same numbers the modulation target will see.\n */\nexport function lfoValueInRange(\n  phase: number,\n  waveform: LFOWaveform = \"sine\",\n  min = -1,\n  max = 1,\n): number {\n  const unit = (lfoValue(phase, waveform) + 1) / 2\n  return min + unit * (max - min)\n}\n\nexport interface LFOVisualizerProps {\n  /** Rate in Hz. Drives the cursor's own clock when `phase` is not supplied. */\n  frequency?: number\n  /** Waveform shape. */\n  type?: LFOWaveform\n  /** Bottom of the modulation range, shown on the readout. */\n  min?: number\n  /** Top of the modulation range, shown on the readout. */\n  max?: number\n  /**\n   * Current phase in turns (0..1), from a real modulator. Supplying this makes\n   * the component controlled: the internal clock stops and the cursor sits\n   * exactly where you put it.\n   */\n  phase?: number\n  /** How many cycles fit across the display. */\n  cycles?: number\n  /** Stop the animation and hold the current frame. */\n  paused?: boolean\n  /** Draw the dotted grid and centre line. */\n  showGrid?: boolean\n  /**\n   * Show the rate and current value as text on the display.\n   *\n   * The Vue original only ever drew its Hz label in the expanded panel; here\n   * it is an unconditional prop, on by default.\n   */\n  showReadout?: boolean\n  /** Trace colour. Defaults to the accent token. */\n  color?: string\n  /** Trace thickness in CSS pixels. */\n  lineWidth?: number\n  width?: number | string\n  height?: number\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function LFOVisualizer({\n  frequency = 4,\n  type = \"sine\",\n  min = -1,\n  max = 1,\n  phase,\n  cycles = 2,\n  paused = false,\n  showGrid = true,\n  showReadout = true,\n  color,\n  lineWidth = 2,\n  width = \"100%\",\n  height = 90,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: LFOVisualizerProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n\n  // Everything the loop reads, in a ref: a frequency change must retune the\n  // running clock, not restart the effect and jump the cursor back to zero.\n  const settingsRef = useRef({\n    frequency,\n    type,\n    min,\n    max,\n    phase,\n    cycles,\n    showGrid,\n    showReadout,\n    color,\n    lineWidth,\n  })\n  useEffect(() => {\n    settingsRef.current = {\n      frequency,\n      type,\n      min,\n      max,\n      phase,\n      cycles,\n      showGrid,\n      showReadout,\n      color,\n      lineWidth,\n    }\n  })\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return\n\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    let raf = 0\n    let w = 0\n    let h = 0\n\n    // The free-running phase, integrated from elapsed time rather than derived\n    // from a wall-clock timestamp: that way a frequency change bends the curve\n    // from where the cursor currently is instead of teleporting it.\n    let clockPhase = 0\n    let lastTime =\n      typeof performance !== \"undefined\" ? performance.now() : Date.now()\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      const rect = canvas.getBoundingClientRect()\n      w = rect.width\n      h = rect.height\n      canvas.width = Math.max(1, Math.floor(w * dpr))\n      canvas.height = Math.max(1, Math.floor(h * dpr))\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    }\n    resize()\n\n    const observer = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null\n    observer?.observe(canvas)\n    if (canvas.parentElement) observer?.observe(canvas.parentElement)\n\n    // The dotted grid is a static function of size and colour, so it is\n    // rasterised once onto an offscreen canvas and blitted with one drawImage\n    // instead of a nested fillRect loop (thousands of 1px rects) every frame.\n    let gridCanvas: HTMLCanvasElement | null = null\n    let gridKey = \"\"\n    const dottedGrid = (gw: number, gh: number, gridColor: string): HTMLCanvasElement | null => {\n      if (gw < 1 || gh < 1) return null\n      const key = `${Math.round(gw)}x${Math.round(gh)}:${gridColor}`\n      if (gridCanvas && gridKey === key) return gridCanvas\n      const layer = gridCanvas ?? document.createElement(\"canvas\")\n      layer.width = Math.max(1, Math.round(gw))\n      layer.height = Math.max(1, Math.round(gh))\n      const gctx = layer.getContext(\"2d\")\n      if (!gctx) return null\n      gctx.clearRect(0, 0, layer.width, layer.height)\n      gctx.fillStyle = gridColor\n      gctx.globalAlpha = 0.5\n      for (let x = 0; x < gw; x += 10) {\n        for (let y = 0; y < gh; y += 10) gctx.fillRect(x, y, 1, 1)\n      }\n      gctx.globalAlpha = 1\n      gridCanvas = layer\n      gridKey = key\n      return layer\n    }\n\n    const render = () => {\n      if (w < 1 || h < 1) {\n        resize()\n        if (w < 1 || h < 1) return\n      }\n\n      const s = settingsRef.current\n      const styles = getComputedStyle(canvas)\n      const stroke = s.color ?? (styles.getPropertyValue(\"--uikit-accent\").trim() || \"#6366f1\")\n      const grid = styles.getPropertyValue(\"--uikit-border\").trim() || \"rgba(148,163,184,0.35)\"\n      const textMuted =\n        styles.getPropertyValue(\"--uikit-text-muted\").trim() || \"rgba(148,163,184,0.9)\"\n\n      ctx.clearRect(0, 0, w, h)\n\n      // 70% of half the height, leaving a 15% margin top and bottom so the\n      // peaks of a square wave are not flush against the border.\n      const amplitude = h * 0.35\n      const mid = h / 2\n      const cycleCount = Math.max(0.25, s.cycles)\n\n      if (s.showGrid) {\n        // Dotted grid, as in the Vue original — it reads as a scope face\n        // without the visual weight of full gridlines. Blitted from a cached\n        // layer rather than redrawn dot-by-dot each frame.\n        const gridLayer = dottedGrid(w, h, grid)\n        if (gridLayer) ctx.drawImage(gridLayer, 0, 0, w, h)\n\n        ctx.strokeStyle = grid\n        ctx.lineWidth = 1\n        ctx.setLineDash([2, 4])\n        ctx.beginPath()\n        ctx.moveTo(0, mid)\n        ctx.lineTo(w, mid)\n        ctx.stroke()\n        ctx.setLineDash([])\n      }\n\n      // The waveform. One sample per pixel keeps a square wave's edges\n      // genuinely vertical instead of stair-stepped across a coarse sample\n      // count, which is what the original's fixed 100 samples produced.\n      ctx.beginPath()\n      ctx.strokeStyle = stroke\n      ctx.lineWidth = s.lineWidth\n      ctx.lineJoin = \"round\"\n      const steps = Math.max(2, Math.ceil(w))\n      for (let i = 0; i <= steps; i += 1) {\n        const x = (i / steps) * w\n        const value = lfoValue((i / steps) * cycleCount, s.type)\n        const y = mid - value * amplitude\n        if (i === 0) ctx.moveTo(x, y)\n        else ctx.lineTo(x, y)\n      }\n      ctx.stroke()\n\n      // The cursor: a vertical line and a dot on the curve at the live phase.\n      const current = s.phase ?? clockPhase\n      const wrapped = ((current % 1) + 1) % 1\n      // The cursor travels one cycle's width, not the whole display, so on a\n      // multi-cycle view it sweeps the first cycle and repeats.\n      const cursorX = (wrapped / cycleCount) * w\n      const cursorValue = lfoValue(wrapped, s.type)\n      const cursorY = mid - cursorValue * amplitude\n\n      ctx.strokeStyle = stroke\n      ctx.globalAlpha = 0.45\n      ctx.lineWidth = 1.5\n      ctx.beginPath()\n      ctx.moveTo(cursorX, 0)\n      ctx.lineTo(cursorX, h)\n      ctx.stroke()\n      ctx.globalAlpha = 1\n\n      ctx.fillStyle = stroke\n      ctx.beginPath()\n      ctx.arc(cursorX, cursorY, Math.max(2, s.lineWidth + 1), 0, Math.PI * 2)\n      ctx.fill()\n\n      if (s.showReadout) {\n        const value = lfoValueInRange(wrapped, s.type, s.min, s.max)\n        ctx.font = \"10px ui-monospace, SFMono-Regular, Menlo, monospace\"\n        ctx.textBaseline = \"top\"\n        ctx.fillStyle = stroke\n        ctx.textAlign = \"left\"\n        ctx.fillText(`${s.frequency.toFixed(2)} Hz`, 5, 5)\n        ctx.fillStyle = textMuted\n        ctx.textAlign = \"right\"\n        ctx.fillText(value.toFixed(2), w - 5, 5)\n      }\n    }\n\n    if (paused) {\n      render()\n      const staticObserver =\n        typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(render) : null\n      staticObserver?.observe(canvas)\n      if (canvas.parentElement) staticObserver?.observe(canvas.parentElement)\n      return () => {\n        observer?.disconnect()\n        staticObserver?.disconnect()\n      }\n    }\n\n    const frame = () => {\n      raf = requestAnimationFrame(frame)\n      const now = typeof performance !== \"undefined\" ? performance.now() : Date.now()\n      const elapsed = (now - lastTime) / 1000\n      lastTime = now\n      // A backgrounded tab can hand back a huge delta on wake; the phase wraps\n      // anyway, so this only avoids a pointlessly large float.\n      clockPhase = (clockPhase + settingsRef.current.frequency * Math.min(elapsed, 1)) % 1\n      render()\n    }\n    raf = requestAnimationFrame(frame)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      observer?.disconnect()\n    }\n  }, [paused])\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? `LFO, ${type} at ${frequency} hertz`}\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",
      "type": "registry:component",
      "target": "@ui/lfo-visualizer.tsx"
    }
  ]
}
