{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "db-readout",
  "title": "dB Readout",
  "description": "A stable-width numeric level readout.",
  "dependencies": [
    "solid-js@^1.9.15"
  ],
  "registryDependencies": [
    "@audiocn-solid/core",
    "@audiocn-solid/use-frame-source"
  ],
  "files": [
    {
      "path": "components/ui/db-readout.tsx",
      "content": "import { For, createMemo } from \"solid-js\";\n\nimport { useFrameSource } from \"@/hooks/use-frame-source\";\nimport { formatDb, SILENCE_DB } from \"@/lib/audio/decibels\";\nimport type { FrameSource, MeterFrame, MeterZone } from \"@/lib/audio/types\";\nimport { DEFAULT_ZONES, zoneForDb } from \"@/lib/audio/zones\";\nimport { createCompatEffect } from \"@/lib/solid/effect\";\nimport type { SpanDOMProps } from \"@/lib/solid/jsx-types\";\nimport { omitProps } from \"@/lib/solid/props\";\nimport { setRefValue } from \"@/lib/solid/ref\";\nimport type { RefTarget } from \"@/lib/solid/ref\";\nimport { mergeStyleVars } from \"@/lib/solid/style\";\nimport type { StyleValue } from \"@/lib/solid/style\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_INTERVAL_MS = 250;\n\nconst DEFAULT_FLOOR_DB = -60;\n\nconst WIDEST_MAGNITUDE_DB = 88.8;\n\ntype SpanProps = Omit<SpanDOMProps, \"class\" | \"className\" | \"ref\" | \"style\"> & {\n  class?: string;\n  className?: string;\n  style?: StyleValue;\n  ref?: RefTarget<HTMLSpanElement>;\n};\n\nconst OWN_PROPS = [\n  \"ref\",\n  \"class\",\n  \"className\",\n  \"style\",\n  \"value\",\n  \"source\",\n  \"measure\",\n  \"channel\",\n  \"intervalMs\",\n  \"holdMs\",\n  \"decimals\",\n  \"unit\",\n  \"floorDb\",\n  \"zones\",\n  \"format\",\n] as const;\n\nconst noop = () => {};\n\nconst createTicker = (step: () => boolean, intervalMs: number) => {\n  let timer: ReturnType<typeof setInterval> | null = null;\n\n  const stop = () => {\n    if (timer !== null) {\n      clearInterval(timer);\n      timer = null;\n    }\n  };\n\n  const wake = () => {\n    if (timer === null) {\n      timer = setInterval(() => {\n        if (!step()) {\n          stop();\n        }\n      }, intervalMs);\n    }\n  };\n\n  return { stop, wake };\n};\n\nexport interface DbReadoutProps extends SpanProps {\n  /** A level in dB, for declarative use. */\n  value?: number;\n  /** A meter source; the readout updates itself without rerendering Solid. */\n  source?: FrameSource<MeterFrame> | null;\n  /** Which measurement to show. Default `peak`. */\n  measure?: \"peak\" | \"rms\";\n  /** A channel index, or `max` for the loudest channel. Default `max`. */\n  channel?: number | \"max\";\n  /** How often the text changes. Default 250 ms. */\n  intervalMs?: number;\n  /** Show the highest value seen within this window. Default 0. */\n  holdMs?: number;\n  /** Digits after the decimal point. Default 1. */\n  decimals?: number;\n  /** Append \" dB\". Default true. */\n  unit?: boolean;\n  /** At or below this level, show \"−∞\". Default −60. */\n  floorDb?: number;\n  /** Zones used for `data-zone`. */\n  zones?: MeterZone[];\n  /** Replaces all formatting. */\n  format?: (db: number) => string;\n}\n\nexport const readChannel = (\n  frame: MeterFrame,\n  measure: \"peak\" | \"rms\",\n  channel: number | \"max\"\n) => {\n  const read = (index: number) => {\n    const level = frame.channels[index];\n\n    if (!level) {\n      return SILENCE_DB;\n    }\n\n    return measure === \"rms\" ? (level.rmsDb ?? level.peakDb) : level.peakDb;\n  };\n\n  if (channel !== \"max\") {\n    return read(channel);\n  }\n\n  let loudest = SILENCE_DB;\n\n  for (let index = 0; index < frame.channels.length; index += 1) {\n    loudest = Math.max(loudest, read(index));\n  }\n\n  return loudest;\n};\n\nexport const DbReadout = (props: DbReadoutProps) => {\n  const rest = omitProps(props, OWN_PROPS);\n  const measure = () => props.measure ?? \"peak\";\n  const channel = () => props.channel ?? \"max\";\n  const intervalMs = () => props.intervalMs ?? DEFAULT_INTERVAL_MS;\n  const holdMs = () => props.holdMs ?? 0;\n  const decimals = () => props.decimals ?? 1;\n  const unit = () => props.unit ?? true;\n  const floorDb = () => props.floorDb ?? DEFAULT_FLOOR_DB;\n  const zones = () => props.zones ?? DEFAULT_ZONES;\n\n  let element: HTMLSpanElement | undefined;\n  let peak = SILENCE_DB;\n  let peakAt = 0;\n  let shown: string | null = null;\n  let fresh = false;\n  let wake = noop;\n\n  const renderDb = (db: number) =>\n    props.format\n      ? props.format(db)\n      : formatDb(db, {\n          decimals: decimals(),\n          floorDb: floorDb(),\n          unit: unit(),\n        });\n\n  const initialDb = createMemo(() => props.value ?? SILENCE_DB);\n\n  const widest = createMemo(() => {\n    const floor = floorDb();\n    const precision = decimals();\n\n    const justAboveFloor = Number.isFinite(floor)\n      ? floor + 10 ** -precision\n      : -WIDEST_MAGNITUDE_DB;\n\n    return Math.max(\n      ...[\n        SILENCE_DB,\n        justAboveFloor,\n        -WIDEST_MAGNITUDE_DB,\n        WIDEST_MAGNITUDE_DB,\n      ].map((db) => renderDb(db).length)\n    );\n  });\n\n  const write = (db: number) => {\n    if (!element) {\n      return false;\n    }\n\n    const text = renderDb(db);\n    const changed = text !== shown;\n\n    if (changed) {\n      shown = text;\n\n      if (element.firstChild) {\n        element.firstChild.nodeValue = text;\n      } else {\n        element.textContent = text;\n      }\n\n      element.dataset.zone = zoneForDb(db, zones());\n      element.toggleAttribute(\"data-silent\", db <= floorDb());\n    }\n\n    return changed;\n  };\n\n  useFrameSource(\n    () => props.source,\n    (frame) => {\n      const db = readChannel(frame, measure(), channel());\n      const now = performance.now();\n\n      if (db >= peak || now - peakAt > holdMs()) {\n        peak = db;\n        peakAt = now;\n      }\n\n      fresh = true;\n      wake();\n    }\n  );\n\n  createCompatEffect(\n    () => ({\n      source: props.source,\n      cadence: intervalMs(),\n    }),\n    ({ source, cadence }) => {\n      if (!source) {\n        return;\n      }\n\n      shown = null;\n      peak = SILENCE_DB;\n      peakAt = 0;\n      fresh = false;\n      write(initialDb());\n\n      const ticker = createTicker(() => {\n        const changed = write(peak);\n\n        if (holdMs() === 0) {\n          peak = SILENCE_DB;\n        }\n\n        const hadFreshFrame = fresh;\n        fresh = false;\n\n        return changed || hadFreshFrame;\n      }, cadence);\n\n      wake = ticker.wake;\n      ticker.wake();\n\n      return () => {\n        wake = noop;\n        ticker.stop();\n      };\n    }\n  );\n\n  const style = createMemo<StyleValue>(() =>\n    mergeStyleVars(props.style, {\n      \"--db-readout-width\": `${widest()}ch`,\n    })\n  );\n\n  const setRef = (node: HTMLSpanElement) => {\n    element = node;\n    setRefValue(props.ref, node);\n  };\n\n  const span = () => (\n    <span\n      class={cn(\n        \"inline-block min-w-(--db-readout-width) text-end font-mono tabular-nums\",\n        props.class,\n        props.className\n      )}\n      data-silent={initialDb() <= floorDb() ? \"\" : undefined}\n      data-slot=\"db-readout\"\n      data-zone={zoneForDb(initialDb(), zones())}\n      ref={setRef}\n      style={style()}\n      {...rest}\n    >\n      {renderDb(initialDb())}\n    </span>\n  );\n\n  return <For each={[Boolean(props.source)]}>{() => span()}</For>;\n};\n",
      "type": "registry:ui"
    },
    {
      "path": "lib/audiocn-license.txt",
      "content": "Audio component source adapted from https://github.com/audiocn/ui.\nMIT License — Copyright (c) 2026 OrcDev\n\nRegular SVG icon geometry adapted from @phosphor-icons/react 2.1.10.\nMIT License — Copyright (c) 2020 Phosphor Icons\n\nThe following permission and warranty terms apply to both notices.\nRetain this file with copies or substantial portions of the source.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
      "type": "registry:file",
      "target": "@lib/audiocn-license.txt"
    }
  ],
  "type": "registry:ui"
}
