{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vintage-oscilloscope",
  "title": "Vintage Oscilloscope",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Traço de osciloscópio alimentado por um buffer de amostras.",
  "registryDependencies": [
    "@visant/vintage-theme",
    "utils"
  ],
  "files": [
    {
      "path": "registry/vintage/ui/vintage-oscilloscope.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * VintageOscilloscope — o tubo, e os cinco programas que passam nele.\n *\n * ── De onde vieram os modos novos ──────────────────────────────────────────\n *\n * `scope`, `rings` e `grid` são porte direto do **7panel_studio**\n * (`Z:/Cursor/7panel_studio/keyboard-ui/src/lib/visualizers/scope.ts`,\n * `rings.ts` e `grid-vis.ts`), onde rodam no visualizador de teclado ao vivo.\n * `particles` e os modos de shader ficaram de fora de propósito: o primeiro\n * depende de `simplex-noise` e os outros de WebGL, e este item promete canvas 2D\n * sem dependência — trazer a lib junto mudaria o preço da peça.\n *\n * ── O que mudou no porte, e por quê ────────────────────────────────────────\n *\n * 1. **As bandas saem do tempo, não de uma FFT.** No 7panel um `AnalyserNode`\n *    entrega seis bandas prontas. Aqui a peça também aceita só um buffer de\n *    amostras, então grave/médio/agudo saem de dois passa-baixas de um polo em\n *    série — três somas por amostra, sem lib. Não é análise de espectro, é\n *    energia por faixa, que é tudo que um desenho reativo consome.\n * 2. **A matiz vem do token.** O original cravava `hsl(120…)`. Aqui a cor\n *    passada em `color` é resolvida pelo navegador (`getComputedStyle`) e vira\n *    a matiz base dos três modos — o fósforo continua sendo escolha de tema.\n * 3. **O laço para quando mandam parar.** `active={false}` desliga o rAF; o\n *    original rodava sempre porque vivia sozinho em tela cheia, e aqui a peça\n *    vive numa grade com uma dúzia de vizinhas.\n * 4. **`prefers-reduced-motion` desenha um quadro e sai.** Traço parado ainda\n *    diz o que a peça é; traço piscando não é negociável com quem pediu calma.\n */\n\nexport type VintageScopeMode = \"wave\" | \"fft\" | \"scope\" | \"rings\" | \"grid\";\n\nexport interface VintageScopeParams {\n  /** Frequência base do traço sintético (modos `wave` e `scope`). */\n  freq?: number;\n  /** Halo em px. Zero deixa o traço seco, como tubo velho. */\n  glow?: number;\n  /** Quanto do quadro anterior sobra (modos com rastro). 0.02 = fantasma longo. */\n  trail?: number;\n}\n\ninterface VintageOscilloscopeProps {\n  /**\n   * Amostras. Em `wave`/`scope`/`rings`/`grid` são do domínio do tempo (−1…1);\n   * em `fft`, magnitudes em dB, como o modo sempre leu.\n   */\n  data?: Float32Array | number[];\n  /**\n   * Fonte ao vivo. Quando vem, manda no `data`: a peça lê o buffer a cada\n   * quadro e não passa por estado do React — sessenta `setState` por segundo\n   * repintariam a árvore inteira pra mexer um pixel.\n   */\n  analyser?: AnalyserNode | null;\n  mode?: VintageScopeMode;\n  params?: VintageScopeParams;\n  color?: string;\n  /** Largura natural do desenho. Com `fill`, vira só o piso do buffer. */\n  width?: number;\n  height?: number;\n  /** Ocupa o pai inteiro em vez de `width`×`height`. É como a TV o encaixa. */\n  fill?: boolean;\n  /** Moldura, scanlines e rótulo do modo. Desligue quando o tubo já é do pai. */\n  chrome?: boolean;\n  /** Relógio. `false` congela o desenho — use com o palco fora da tela. */\n  active?: boolean;\n  className?: string;\n}\n\n/** Energia por faixa. É o mínimo que os desenhos portados consomem. */\ninterface Bandas {\n  bass: number;\n  mid: number;\n  high: number;\n  volume: number;\n  kick: boolean;\n  snare: boolean;\n  hat: boolean;\n}\n\nconst BANDAS_ZERO: Bandas = {\n  bass: 0,\n  mid: 0,\n  high: 0,\n  volume: 0,\n  kick: false,\n  snare: false,\n  hat: false,\n};\n\n/** Estado que sobrevive entre quadros. Um por instância, guardado em ref. */\ninterface Estado {\n  fase: number;\n  historico: Float32Array[];\n  flash: number;\n  aneis: {\n    raio: number;\n    raioMax: number;\n    alfa: number;\n    matiz: number;\n    largura: number;\n    passo: number;\n  }[];\n  colunas: Float32Array;\n  t: number;\n  varreduraInvertida: boolean;\n  histBass: number[];\n  histMid: number[];\n  histHigh: number[];\n  esperaKick: number;\n  esperaSnare: number;\n  esperaHat: number;\n  lp1: number;\n  lp2: number;\n}\n\nfunction estadoNovo(): Estado {\n  return {\n    fase: 0,\n    historico: [],\n    flash: 0,\n    aneis: [],\n    colunas: new Float32Array(32),\n    t: 0,\n    varreduraInvertida: false,\n    histBass: [],\n    histMid: [],\n    histHigh: [],\n    esperaKick: 0,\n    esperaSnare: 0,\n    esperaHat: 0,\n    lp1: 0,\n    lp2: 0,\n  };\n}\n\n/**\n * Bandas a partir do domínio do tempo.\n *\n * Dois passa-baixas de um polo em série (0.06 e 0.28 de coeficiente, ~ 300 Hz e\n * ~ 2 kHz a 44,1 kHz): o primeiro isola o grave, a diferença entre os dois é o\n * médio e o que sobra do sinal cru é o agudo. É o divisor de três vias mais\n * barato que existe, e como só interessa a ENERGIA de cada faixa, a fase torta\n * que ele produz não aparece no desenho.\n */\nfunction lerBandas(amostras: ArrayLike<number>, st: Estado): Bandas {\n  const n = amostras.length;\n  if (n === 0) return BANDAS_ZERO;\n\n  let somaB = 0;\n  let somaM = 0;\n  let somaA = 0;\n  let somaV = 0;\n  let lp1 = st.lp1;\n  let lp2 = st.lp2;\n\n  for (let i = 0; i < n; i++) {\n    const x = amostras[i];\n    lp1 += (x - lp1) * 0.06;\n    lp2 += (x - lp2) * 0.28;\n    const m = lp2 - lp1;\n    const a = x - lp2;\n    somaB += lp1 * lp1;\n    somaM += m * m;\n    somaA += a * a;\n    somaV += x * x;\n  }\n  st.lp1 = lp1;\n  st.lp2 = lp2;\n\n  // O grave sai do passa-baixa com amplitude bem menor que o sinal; os fatores\n  // só recolocam as três faixas na mesma régua de 0…1.\n  const bass = Math.min(1, Math.sqrt(somaB / n) * 3.2);\n  const mid = Math.min(1, Math.sqrt(somaM / n) * 2.6);\n  const high = Math.min(1, Math.sqrt(somaA / n) * 2.2);\n  const volume = Math.min(1, Math.sqrt(somaV / n) * 1.8);\n\n  const media = (arr: number[]) => {\n    if (arr.length === 0) return 0;\n    let s = 0;\n    for (let i = 0; i < arr.length; i++) s += arr[i];\n    return s / arr.length;\n  };\n  const empurra = (arr: number[], v: number) => {\n    arr.push(v);\n    if (arr.length > 20) arr.shift();\n  };\n  empurra(st.histBass, bass);\n  empurra(st.histMid, mid);\n  empurra(st.histHigh, high);\n\n  st.esperaKick = Math.max(0, st.esperaKick - 1);\n  st.esperaSnare = Math.max(0, st.esperaSnare - 1);\n  st.esperaHat = Math.max(0, st.esperaHat - 1);\n\n  let kick = false;\n  let snare = false;\n  let hat = false;\n  // Batida é energia acima da própria média recente, e não acima de um limiar\n  // fixo: com limiar fixo a peça só reage a sinal alto, e um sinal baixo vira\n  // desenho morto em vez de desenho discreto.\n  if (st.esperaKick <= 0 && bass > media(st.histBass) * 1.4 && bass > 0.12) {\n    kick = true;\n    st.esperaKick = 8;\n  }\n  if (st.esperaSnare <= 0 && mid > media(st.histMid) * 1.3 && mid > 0.08) {\n    snare = true;\n    st.esperaSnare = 5;\n  }\n  if (st.esperaHat <= 0 && high > media(st.histHigh) * 1.3 && high > 0.06) {\n    hat = true;\n    st.esperaHat = 3;\n  }\n\n  return { bass, mid, high, volume, kick, snare, hat };\n}\n\nconst MATIZES = new Map<string, number>();\n\n/**\n * Os canais da cor, pintando um pixel. A cor computada pode vir em `oklch()` ou `color-mix`, e\n * ler os números dela como RGB dava sempre a mesma matiz, em qualquer marca.\n */\nfunction canaisDe(cor: string): [number, number, number] | null {\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.fillStyle = cor;\n  ctx.fillRect(0, 0, 1, 1);\n  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n  return [r, g, b];\n}\n\n/** A matiz do fósforo, resolvida do token. Cai no verde de tubo se falhar. */\nfunction matizDe(el: HTMLElement | null): number {\n  if (!el || typeof window === \"undefined\") return 120;\n  const cor = getComputedStyle(el).color;\n  const guardada = MATIZES.get(cor);\n  if (guardada !== undefined) return guardada;\n  const canais = canaisDe(cor);\n  if (!canais) return 120;\n  const matiz = matizDosCanais(canais[0] / 255, canais[1] / 255, canais[2] / 255);\n  MATIZES.set(cor, matiz);\n  return matiz;\n}\n\nfunction matizDosCanais(r: number, g: number, b: number): number {\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  const d = max - min;\n  if (d === 0) return 120;\n  let h: number;\n  if (max === r) h = ((g - b) / d) % 6;\n  else if (max === g) h = (b - r) / d + 2;\n  else h = (r - g) / d + 4;\n  h *= 60;\n  return h < 0 ? h + 360 : h;\n}\n\nfunction grade(ctx: CanvasRenderingContext2D, w: number, h: number, matiz: number) {\n  ctx.strokeStyle = `hsla(${matiz}, 50%, 40%, 0.09)`;\n  ctx.lineWidth = 0.5;\n  for (let i = 1; i < 8; i++) {\n    const x = (w / 8) * i;\n    ctx.beginPath();\n    ctx.moveTo(x, 0);\n    ctx.lineTo(x, h);\n    ctx.stroke();\n  }\n  for (let i = 1; i < 4; i++) {\n    const y = (h / 4) * i;\n    ctx.beginPath();\n    ctx.moveTo(0, y);\n    ctx.lineTo(w, y);\n    ctx.stroke();\n  }\n}\n\n// ── Modo `wave` — o traço original, sem rastro ────────────────────────────────\n\nfunction desenharWave(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  amostras: ArrayLike<number> | null,\n  cor: string,\n  p: VintageScopeParams,\n) {\n  ctx.fillStyle = \"rgba(8,10,7,0.92)\";\n  ctx.fillRect(0, 0, w, h);\n  ctx.strokeStyle = \"rgba(255,255,255,0.04)\";\n  ctx.lineWidth = 1;\n  for (let i = 1; i < 8; i++) {\n    const x = (w / 8) * i;\n    ctx.beginPath();\n    ctx.moveTo(x, 0);\n    ctx.lineTo(x, h);\n    ctx.stroke();\n  }\n  for (let i = 1; i < 4; i++) {\n    const y = (h / 4) * i;\n    ctx.beginPath();\n    ctx.moveTo(0, y);\n    ctx.lineTo(w, y);\n    ctx.stroke();\n  }\n\n  ctx.strokeStyle = cor;\n  ctx.shadowColor = cor;\n  ctx.shadowBlur = p.glow ?? 8;\n  ctx.lineWidth = 1.5;\n  ctx.beginPath();\n  if (!amostras || amostras.length === 0) {\n    ctx.moveTo(0, h / 2);\n    ctx.lineTo(w, h / 2);\n  } else {\n    for (let i = 0; i < amostras.length; i++) {\n      const x = (i / (amostras.length - 1)) * w;\n      const y = h / 2 - amostras[i] * (h / 2) * 0.9;\n      if (i === 0) ctx.moveTo(x, y);\n      else ctx.lineTo(x, y);\n    }\n  }\n  ctx.stroke();\n  ctx.shadowBlur = 0;\n}\n\n// ── Modo `fft` — barras log, como já era ──────────────────────────────────────\n\nfunction desenharFft(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  amostras: ArrayLike<number> | null,\n  cor: string,\n) {\n  ctx.fillStyle = \"rgba(8,10,7,0.92)\";\n  ctx.fillRect(0, 0, w, h);\n  if (!amostras || amostras.length === 0) return;\n  const barras = Math.min(amostras.length / 2, 128);\n  ctx.fillStyle = cor;\n  ctx.globalAlpha = 0.7;\n  for (let i = 0; i < barras; i++) {\n    const logI = Math.pow(i / barras, 2) * (amostras.length / 2);\n    const idx = Math.min(Math.floor(logI), amostras.length - 1);\n    const norm = Math.max(0, Math.min(1, (amostras[idx] + 100) / 100));\n    const bw = w / barras;\n    ctx.fillRect(i * bw, h - norm * h, bw - 1, norm * h);\n  }\n  ctx.globalAlpha = 1;\n}\n\n// ── Modo `scope` — porte de 7panel_studio/.../visualizers/scope.ts ────────────\n\nfunction desenharScope(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  amostras: ArrayLike<number> | null,\n  b: Bandas,\n  matiz: number,\n  p: VintageScopeParams,\n  st: Estado,\n) {\n  const trail = p.trail ?? 0.15;\n  const glow = p.glow ?? 8;\n  const maxHist = 8;\n\n  if (b.kick) st.flash = 1;\n  st.flash *= 0.85;\n\n  // Apagamento parcial: é o rastro. Na batida ele apaga menos, e a sobra do\n  // quadro anterior vira o \"borrão\" que um tubo de fósforo lento faz sozinho.\n  ctx.fillStyle = `hsla(${matiz}, 40%, 3%, ${trail * (1 - st.flash * 0.6)})`;\n  ctx.fillRect(0, 0, w, h);\n  grade(ctx, w, h, matiz);\n\n  const cy = h / 2;\n  const passos = Math.max(2, Math.floor(w));\n  const freq = (p.freq ?? 2) + b.mid * 6;\n  st.fase += 0.02 + b.bass * 0.08;\n  const amp = 0.3 + b.volume * 0.7;\n\n  const pts = new Float32Array(passos);\n  for (let i = 0; i < passos; i++) {\n    const x = i / passos;\n    if (amostras && amostras.length > 0) {\n      const src = x * (amostras.length - 1);\n      const lo = Math.floor(src);\n      const hi = Math.min(lo + 1, amostras.length - 1);\n      const f = src - lo;\n      pts[i] = amostras[lo] * (1 - f) + amostras[hi] * f;\n    } else {\n      // Sem sinal o tubo não fica reto: gera o próprio programa, senão a peça\n      // parada lê como peça quebrada.\n      let y = Math.sin(x * Math.PI * 2 * freq + st.fase) * amp;\n      y += Math.sin(x * Math.PI * 2 * freq * 2.01 + st.fase * 1.5) * amp * 0.3;\n      y += Math.cos(x * Math.PI * 2 * 0.5 + st.fase * 0.3) * amp * 0.5;\n      pts[i] = y;\n    }\n  }\n\n  st.historico.push(pts);\n  if (st.historico.length > maxHist) st.historico.shift();\n\n  for (let hi = 0; hi < st.historico.length; hi++) {\n    const idade = (hi + 1) / st.historico.length;\n    ctx.strokeStyle = `hsla(${matiz}, 80%, 65%, ${idade * 0.15})`;\n    ctx.lineWidth = 0.5;\n    ctx.beginPath();\n    const linha = st.historico[hi];\n    for (let i = 0; i < linha.length; i++) {\n      const x = (i / (linha.length - 1)) * w;\n      const y = cy - linha[i] * (h * 0.45);\n      if (i === 0) ctx.moveTo(x, y);\n      else ctx.lineTo(x, y);\n    }\n    ctx.stroke();\n  }\n\n  const kick = st.flash;\n  ctx.strokeStyle = `hsla(${matiz}, 80%, ${65 + kick * 30}%, ${0.75 + b.volume * 0.25})`;\n  ctx.lineWidth = 1.5 + b.bass * 2 + kick * 3;\n  ctx.shadowColor = `hsla(${matiz}, 80%, 70%, ${0.8 + (b.snare ? 0.2 : 0)})`;\n  ctx.shadowBlur = glow + b.volume * 12 + (b.snare ? 20 : 0) + kick * 15;\n  ctx.beginPath();\n  for (let i = 0; i < pts.length; i++) {\n    const x = (i / (pts.length - 1)) * w;\n    const y = cy - pts[i] * (h * 0.45);\n    if (i === 0) ctx.moveTo(x, y);\n    else ctx.lineTo(x, y);\n  }\n  ctx.stroke();\n  ctx.shadowBlur = 0;\n\n  ctx.strokeStyle = `hsla(${matiz}, 80%, 65%, 0.06)`;\n  ctx.lineWidth = 0.5;\n  ctx.beginPath();\n  ctx.moveTo(0, cy);\n  ctx.lineTo(w, cy);\n  ctx.stroke();\n}\n\n// ── Modo `rings` — porte de 7panel_studio/.../visualizers/rings.ts ────────────\n\nfunction desenharRings(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  b: Bandas,\n  matiz: number,\n  p: VintageScopeParams,\n  st: Estado,\n) {\n  st.t += 0.016;\n  ctx.fillStyle = `hsla(${matiz}, 30%, 3%, ${0.1 + b.volume * 0.05})`;\n  ctx.fillRect(0, 0, w, h);\n\n  const cx = w / 2;\n  const cy = h / 2;\n  const maxR = Math.min(w, h) * 0.45;\n\n  if (b.kick) {\n    st.aneis.push({\n      raio: 5,\n      raioMax: maxR * 0.95,\n      alfa: 0.9,\n      matiz: (matiz + 360 + 8 * Math.sin(st.t * 0.4)) % 360,\n      largura: 3 + b.bass * 4,\n      passo: 3 + b.bass * 4,\n    });\n  }\n  if (b.snare) {\n    st.aneis.push({\n      raio: 8,\n      raioMax: maxR * 0.6,\n      alfa: 0.6,\n      // Um fósforo, uma matiz: a deriva é de poucos graus, não o arco-íris\n      // inteiro, que fazia o tubo mudar de fabricante no meio da música.\n      matiz: (matiz + 360 + 10 * Math.sin(st.t * 0.5)) % 360,\n      largura: 1.5 + b.mid * 2,\n      passo: 3,\n    });\n  }\n  if (b.hat) {\n    for (let i = 0; i < 3; i++) {\n      st.aneis.push({\n        raio: 3,\n        raioMax: maxR * (0.2 + Math.random() * 0.2),\n        alfa: 0.4,\n        matiz: (matiz + 360 + (i - 1) * 6) % 360,\n        largura: 0.5,\n        passo: 4 + Math.random() * 3,\n      });\n    }\n  }\n  // Rega ambiente: sem ela um sinal calmo deixa a tela vazia, e vazia lê como\n  // desligada. O original tinha a mesma válvula, pela mesma razão.\n  if (st.aneis.length < 3 && Math.random() < 0.08) {\n    st.aneis.push({\n      raio: 2,\n      raioMax: maxR * 0.5,\n      alfa: 0.25,\n      matiz,\n      largura: 1,\n      passo: 1.5,\n    });\n  }\n\n  for (let i = st.aneis.length - 1; i >= 0; i--) {\n    const r = st.aneis[i];\n    r.raio += r.passo;\n    const idade = r.raio / r.raioMax;\n    const a = r.alfa * (1 - idade * idade);\n    if (a > 0.01 && r.raio < r.raioMax) {\n      ctx.beginPath();\n      ctx.arc(cx, cy, r.raio, 0, Math.PI * 2);\n      ctx.strokeStyle = `hsla(${r.matiz}, 70%, 60%, ${a})`;\n      ctx.lineWidth = r.largura * (1 - idade * 0.5);\n      ctx.shadowColor = `hsla(${r.matiz}, 80%, 70%, ${a * 0.5})`;\n      ctx.shadowBlur = (p.glow ?? 6) + b.volume * 8;\n      ctx.stroke();\n      ctx.shadowBlur = 0;\n    }\n    if (r.raio >= r.raioMax) st.aneis.splice(i, 1);\n  }\n  if (st.aneis.length > 40) st.aneis.splice(0, st.aneis.length - 40);\n\n  // Arcos girando por banda: é o que dá leitura de \"medidor\" ao anel, senão\n  // seria só água na poça.\n  const faixas = [b.bass, b.mid, b.high, b.volume, b.bass, b.mid];\n  for (let i = 0; i < faixas.length; i++) {\n    const ang = (i / faixas.length) * Math.PI * 2 + st.t * 0.5;\n    const v = faixas[i];\n    const r = maxR * 0.62 + v * maxR * 0.32;\n    ctx.beginPath();\n    ctx.arc(cx, cy, r, ang, ang + 0.3 + v * 0.4);\n    ctx.strokeStyle = `hsla(${(matiz + i * 3) % 360}, 60%, 58%, ${0.12 + v * 0.35})`;\n    ctx.lineWidth = 1 + v * 2;\n    ctx.stroke();\n  }\n}\n\n// ── Modo `grid` — porte de 7panel_studio/.../visualizers/grid-vis.ts ──────────\n\nfunction desenharGrid(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  amostras: ArrayLike<number> | null,\n  b: Bandas,\n  matiz: number,\n  st: Estado,\n) {\n  if (b.kick) st.flash = 1;\n  if (b.snare) st.varreduraInvertida = !st.varreduraInvertida;\n  st.flash *= 0.88;\n  st.t += 0.016;\n\n  ctx.fillStyle = `hsla(${matiz}, 35%, 4%, 1)`;\n  ctx.fillRect(0, 0, w, h);\n\n  const cols = Math.max(8, Math.min(40, Math.round(w / 14)));\n  const rows = Math.max(4, Math.min(20, Math.round(h / 12)));\n  if (st.colunas.length !== cols) st.colunas = new Float32Array(cols);\n\n  const vao = 2;\n  const cw = (w - vao * (cols + 1)) / cols;\n  const ch = (h - vao * (rows + 1)) / rows;\n  const faixas = [b.bass, b.bass, b.mid, b.mid, b.high, b.high, b.volume];\n\n  // Energia por coluna vinda da onda: é o que faz a grade \"andar\" com o sinal\n  // em vez de pulsar em bloco.\n  const energia = new Float32Array(cols);\n  if (amostras && amostras.length > 0) {\n    const razao = amostras.length / cols;\n    for (let c = 0; c < cols; c++) {\n      const lo = Math.floor(c * razao);\n      const hi = Math.min(Math.floor((c + 1) * razao), amostras.length);\n      let s = 0;\n      for (let j = lo; j < hi; j++) s += Math.abs(amostras[j]);\n      energia[c] = hi > lo ? (s / (hi - lo)) * 2 : 0;\n    }\n  }\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const x = vao + c * (cw + vao);\n      const y = vao + r * (ch + vao);\n      const idx = Math.floor((c / cols) * faixas.length);\n      const faixa = faixas[Math.min(idx, faixas.length - 1)];\n      const fatorLinha = 1 - Math.abs(r - rows / 2) / (rows / 2);\n      const alvo = faixa * 0.4 + energia[c] * 0.6;\n      st.colunas[c] += (alvo - st.colunas[c]) * 0.3;\n      const v = Math.max(0, st.colunas[c] * fatorLinha);\n\n      const escala = 0.3 + Math.min(1, v) * 0.7;\n      const dh = ch * escala;\n      const dy = y + (ch - dh) / 2;\n      const mt = (matiz + 360 + 8 * Math.sin(idx * 0.3 + st.t * 0.2)) % 360;\n      ctx.fillStyle = `hsla(${mt}, ${50 + v * 30}%, ${32 + v * 40}%, ${0.35 + v * 0.65})`;\n      ctx.fillRect(x, dy, cw, dh);\n    }\n  }\n\n  if (st.flash > 0.05) {\n    ctx.fillStyle = `rgba(255,255,255,${st.flash * 0.1})`;\n    ctx.fillRect(0, 0, w, h);\n  }\n\n  const dir = st.varreduraInvertida ? -1 : 1;\n  const span = w + 40;\n  const sx = ((((st.t * 80 * dir + b.bass * 200) % span) + span) % span) - 20;\n  ctx.fillStyle = `rgba(255,255,255,${0.02 + b.volume * 0.03})`;\n  ctx.fillRect(sx - 2, 0, 4, h);\n}\n\nconst ROTULO: Record<VintageScopeMode, string> = {\n  wave: \"WAVE\",\n  fft: \"FFT\",\n  scope: \"SCOPE\",\n  rings: \"RINGS\",\n  grid: \"GRID\",\n};\n\nexport function VintageOscilloscope({\n  data,\n  analyser,\n  mode = \"wave\",\n  params,\n  color = \"var(--vintage-light)\",\n  width = 320,\n  height = 120,\n  fill = false,\n  chrome = true,\n  active = true,\n  className,\n}: VintageOscilloscopeProps) {\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const rafRef = React.useRef(0);\n  const estadoRef = React.useRef<Estado>(estadoNovo());\n  // `Float32Array<ArrayBuffer>` e não `Float32Array` solto: desde o TS 5.7 o\n  // tipo é genérico no buffer, e `getFloatTimeDomainData` recusa o que puder\n  // ser `SharedArrayBuffer`.\n  const bufRef = React.useRef<Float32Array<ArrayBuffer> | null>(null);\n\n  // Props lidas dentro do laço de desenho: guardá-las em ref é o que impede o\n  // rAF de ser desmontado e remontado a cada quadro de um pai que rerenderiza.\n  const vivo = React.useRef({ data, analyser, mode, params, color });\n  vivo.current = { data, analyser, mode, params, color };\n\n  React.useEffect(() => {\n    estadoRef.current = estadoNovo();\n  }, [mode]);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const box = boxRef.current;\n    if (!canvas || !box) return;\n\n    let calmo = false;\n    try {\n      calmo = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    } catch {\n      calmo = false;\n    }\n\n    let rodando = true;\n    let larguraCss = 0;\n    let alturaCss = 0;\n\n    const dimensionar = () => {\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      const cw = fill ? Math.max(1, Math.round(box.clientWidth)) : width;\n      const chh = fill ? Math.max(1, Math.round(box.clientHeight)) : height;\n      if (cw === larguraCss && chh === alturaCss) return false;\n      larguraCss = cw;\n      alturaCss = chh;\n      // Mexer em `canvas.width` limpa o buffer, então só na mudança de tamanho:\n      // fazer isso todo quadro apagaria justamente o rastro dos modos com cauda.\n      canvas.width = Math.round(cw * dpr);\n      canvas.height = Math.round(chh * dpr);\n      canvas.style.width = `${cw}px`;\n      canvas.style.height = `${chh}px`;\n      return true;\n    };\n\n    const quadro = () => {\n      if (!rodando) return;\n      dimensionar();\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const { data: d, analyser: an, mode: m, params: p, color: c } = vivo.current;\n      const st = estadoRef.current;\n      const matiz = matizDe(box);\n      const cor = getComputedStyle(box).color || c;\n\n      let amostras: ArrayLike<number> | null = null;\n      if (an) {\n        if (!bufRef.current || bufRef.current.length !== an.fftSize) {\n          bufRef.current = new Float32Array(an.fftSize);\n        }\n        an.getFloatTimeDomainData(bufRef.current);\n        amostras = bufRef.current;\n      } else if (d && d.length > 0) {\n        amostras = d;\n      }\n\n      const w = larguraCss;\n      const h = alturaCss;\n      const pp = p ?? {};\n\n      if (m === \"fft\") {\n        desenharFft(ctx, w, h, amostras, cor);\n      } else if (m === \"wave\") {\n        desenharWave(ctx, w, h, amostras, cor, pp);\n      } else {\n        const b = amostras ? lerBandas(amostras, st) : BANDAS_ZERO;\n        if (m === \"scope\") desenharScope(ctx, w, h, amostras, b, matiz, pp, st);\n        else if (m === \"rings\") desenharRings(ctx, w, h, b, matiz, pp, st);\n        else desenharGrid(ctx, w, h, amostras, b, matiz, st);\n      }\n\n      // Parado ainda desenha UM quadro: card fora da tela com tubo em branco lê\n      // como peça quebrada, e o que se quer economizar é o laço, não a imagem.\n      if (calmo || !active) return;\n      rafRef.current = requestAnimationFrame(quadro);\n    };\n\n    quadro();\n\n    const ro = fill && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(() => {\n      if (!active || calmo) quadro();\n    }) : null;\n    ro?.observe(box);\n\n    return () => {\n      rodando = false;\n      cancelAnimationFrame(rafRef.current);\n      ro?.disconnect();\n    };\n  }, [active, fill, width, height]);\n\n  return (\n    <div\n      ref={boxRef}\n      className={cn(\"relative overflow-hidden\", chrome && \"rounded-[6px]\", fill ? \"block h-full w-full\" : \"inline-block\", className)}\n      // `color` no contêiner e não no canvas: é daqui que a matiz do fósforo é\n      // resolvida, e é o que mantém a cor sendo token em vez de hex no JS.\n      style={{\n        color,\n        background: \"var(--vintage-tube)\",\n        // Janela rebaixada na chapa, a mesma do EQ e do XY: sombra por dentro\n        // em cima, lábio de luz embaixo. Luz de cima, sempre.\n        boxShadow: chrome\n          ? \"var(--vintage-well-shadow)\"\n          : undefined,\n        width: fill ? undefined : width,\n        height: fill ? undefined : height,\n      }}\n    >\n      <canvas ref={canvasRef} style={{ display: \"block\" }} />\n\n      {chrome && (\n        <>\n          <div\n            className=\"pointer-events-none absolute inset-0\"\n            style={{\n              background:\n                \"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)\",\n            }}\n          />\n          <div\n            className=\"pointer-events-none absolute inset-0 rounded-[6px]\"\n            style={{\n              // Vinheta do tubo e o reflexo do vidro, vindo de cima.\n              boxShadow: \"inset 0 0 30px rgba(0,0,0,0.45)\",\n              background:\n                \"linear-gradient(170deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.02) 35%, transparent 36%)\",\n            }}\n          />\n          <div\n            className=\"pointer-events-none absolute right-2 top-1.5 text-[9px] font-bold uppercase tracking-wider\"\n            style={{\n              // Era `opacity: 0.5`, que sobre o fundo do tubo dá 2,9:1 — abaixo\n              // do piso de 3:1 pra texto. Em 0,9 o rótulo continua discreto\n              // (é fósforo apagando) e passa.\n              color: \"currentColor\",\n              opacity: 0.9,\n              fontFamily: \"var(--font-vintage-mono, monospace)\",\n              textShadow: \"0 0 4px currentColor\",\n            }}\n          >\n            {ROTULO[mode]}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Desenhar forma de onda ao vivo a partir de um buffer de áudio, em synth ou analisador",
      "evite": "Áudio gravado com play e busca: voice-note; série de dados no tempo, streamgraph"
    }
  },
  "categories": [
    "conteudo",
    "audio",
    "canvas"
  ],
  "type": "registry:ui"
}