{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "knob",
  "title": "Knob",
  "description": "A composable rotary parameter control with linear or circular dragging, keyboard and wheel input, editable values, scales, metal caps, and optional detent clicks.",
  "dependencies": [
    "solid-js@^1.9.15",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@audiocn-solid/core",
    "@audiocn-solid/use-audio-config",
    "@audiocn-solid/use-audio-context"
  ],
  "files": [
    {
      "path": "components/ui/knob.tsx",
      "content": "import { cva } from \"class-variance-authority\";\nimport {\n  createContext,\n  createMemo,\n  createSignal,\n  createUniqueId,\n  For,\n  Show,\n  useContext,\n} from \"solid-js\";\n\nimport { useAudioConfig } from \"@/hooks/use-audio-config\";\nimport type { AudioSize } from \"@/hooks/use-audio-config\";\nimport {\n  getSharedAudioContext,\n  resumeAudioContext,\n  useProvidedAudioContext,\n} from \"@/hooks/use-audio-context\";\nimport type { ClickSound } from \"@/lib/audio/click\";\nimport { createClickSound } from \"@/lib/audio/click\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { linearTaper, logTaper } from \"@/lib/audio/taper\";\nimport type { Taper } from \"@/lib/audio/types\";\nimport { roundValue, widestFormattedValue } from \"@/lib/number\";\nimport { provideContext } from \"@/lib/solid/context\";\nimport { createCompatEffect } from \"@/lib/solid/effect\";\nimport type {\n  DivDOMProps,\n  GroupDOMProps,\n  JSXElement,\n  LineDOMProps,\n  PathDOMProps,\n  SpanDOMProps,\n} from \"@/lib/solid/jsx-types\";\nimport { omitProps } from \"@/lib/solid/props\";\nimport { mergeStyleVars } from \"@/lib/solid/style\";\nimport type { StyleValue } from \"@/lib/solid/style\";\nimport { cn } from \"@/lib/utils\";\n\nconst VIEWBOX = 100;\n\nconst CENTER = 50;\n\nconst RADIUS = 40;\n\nconst FINE_FACTOR = 0.1;\n\nconst DEGREES_TO_RADIANS = Math.PI / 180;\n\nconst HALF_TURN = 180;\n\nconst WIDTH_SAMPLES = 24;\n\nconst NUMBER = /[-+]?(?:\\d+\\.?\\d*|\\.\\d+)/u;\n\nconst THOUSANDS = /\\d\\s*k/iu;\n\nconst THOUSAND = 1000;\n\nconst COORDINATE_PRECISION = 1e4;\n\nconst DEAD_ZONE = 0.25;\n\nconst SCALE = {\n  label: 47.5,\n  labelSize: 4.5,\n  majorInner: 34.5,\n  majorOuter: 43.5,\n  minorInner: 35.5,\n  minorOuter: 40.5,\n} as const;\n\nconst CAP = {\n  default: {\n    bezel: 25.5,\n    face: 22.5,\n    halo: 34,\n    haloFrom: 0.6,\n    rim: 23.3,\n  },\n  mini: {\n    bezel: 32,\n    face: 28.2,\n    halo: 36,\n    haloFrom: 0.8,\n    rim: 29.2,\n  },\n} as const;\n\nconst CAP_DOT = {\n  distance: 17,\n  radius: 1.6,\n} as const;\n\nconst CAP_LINE = {\n  inner: 8.5,\n  lip: 0.6,\n  outer: 23,\n  width: 3.4,\n} as const;\n\nconst TICK_EPSILON = 1e-9;\n\nconst DEFAULT_KNOB_CLICK = createClickSound();\n\nconst DETENT_EPSILON = 1e-9;\n\nexport type KnobChangeReason =\n  | \"drag\"\n  | \"keyboard\"\n  | \"wheel\"\n  | \"reset\"\n  | \"input\";\n\nexport interface KnobChangeDetails {\n  reason: KnobChangeReason;\n  event?: Event;\n}\n\ntype KnobDragDirection = \"vertical\" | \"horizontal\" | \"circular\";\n\ninterface KnobContextValue {\n  arc: () => number;\n  disabled: () => boolean;\n  editing: () => boolean;\n  format: () => (value: number) => string;\n  labelId: string;\n  originPosition: () => number;\n  parse: () => (text: string) => number | null;\n  position: () => number;\n  setEditing: (editing: boolean) => void;\n  value: () => number;\n  valueWidth: () => number;\n}\n\ninterface KnobDialContextValue {\n  allowWheel: () => boolean;\n  change: (value: number, details: KnobChangeDetails) => void;\n  commit: (value: number) => void;\n  dragDirection: () => KnobDragDirection;\n  fineStep: () => number;\n  largeStep: () => number;\n  latest: () => number;\n  max: () => number;\n  min: () => number;\n  quantize: (value: number, increment: number) => number;\n  resetValue: () => number;\n  sensitivity: () => number;\n  setDetents: (positions: readonly number[] | null) => void;\n  step: () => number;\n  taper: () => Taper;\n}\n\nexport interface DialProviderProps {\n  /** The turn shown by the cap and pointer, in degrees clockwise from 12. */\n  angle: number;\n  onCapRadiusChange?: (radius: number | null) => void;\n  children?: JSXElement;\n}\n\ninterface DialContextValue {\n  angle: () => number;\n  onCapRadiusChange?: (radius: number | null) => void;\n}\n\nconst KnobContext = createContext<KnobContextValue | null>(null);\n\nconst KnobDialContext = createContext<KnobDialContextValue | null>(null);\n\nconst DialContext = createContext<DialContextValue | null>(null);\n\nconst useDial = (part: string) => {\n  const context = useContext(DialContext);\n\n  if (!context) {\n    throw new Error(`${part} must be used inside a dial, like KnobDial.`);\n  }\n\n  return context;\n};\n\nexport const DialProvider = (props: DialProviderProps) =>\n  provideContext(\n    DialContext,\n    {\n      angle: () => props.angle,\n      onCapRadiusChange: (radius) => props.onCapRadiusChange?.(radius),\n    },\n    () => props.children\n  );\n\nconst useKnob = (part: string): KnobContextValue => {\n  const context = useContext(KnobContext);\n\n  if (!context) {\n    throw new Error(`${part} must be used inside Knob.`);\n  }\n\n  return context;\n};\n\nconst useKnobDial = (): KnobDialContextValue => {\n  const context = useContext(KnobDialContext);\n\n  if (!context) {\n    throw new Error(\"KnobDial must be used inside Knob.\");\n  }\n\n  return context;\n};\n\n/**\n * Reads the first number in text. Unicode minus is accepted and\n * \"k\" means thousands.\n */\nexport const parseKnobValue = (text: string): number | null => {\n  const normalized = text.replaceAll(\"\\u2212\", \"-\");\n  const match = NUMBER.exec(normalized);\n\n  if (!match) {\n    return null;\n  }\n\n  const number = Number(match[0]);\n\n  return THOUSANDS.test(normalized) ? number * THOUSAND : number;\n};\n\nconst angleFor = (position: number, arc: number): number =>\n  -arc / 2 + position * arc;\n\nconst roundCoordinate = (value: number): number =>\n  Math.round(value * COORDINATE_PRECISION) / COORDINATE_PRECISION;\n\nconst pointAt = (angle: number, radius: number) => {\n  const radians = angle * DEGREES_TO_RADIANS;\n\n  return {\n    x: roundCoordinate(CENTER + radius * Math.sin(radians)),\n    y: roundCoordinate(CENTER - radius * Math.cos(radians)),\n  };\n};\n\nconst arcPath = (\n  fromAngle: number,\n  toAngle: number,\n  radius = RADIUS\n): string => {\n  const start = Math.min(fromAngle, toAngle);\n  const end = Math.max(fromAngle, toAngle);\n\n  if (end - start < 0.01) {\n    return \"\";\n  }\n\n  const from = pointAt(start, radius);\n\n  const to = pointAt(end, radius);\n\n  const largeArc = end - start > HALF_TURN ? 1 : 0;\n\n  return `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`;\n};\n\nconst keyTarget = (\n  key: string,\n  current: number,\n  increment: number,\n  dial: KnobDialContextValue\n): number | null => {\n  switch (key) {\n    case \"ArrowDown\":\n    case \"ArrowLeft\": {\n      return current - increment;\n    }\n\n    case \"ArrowRight\":\n    case \"ArrowUp\": {\n      return current + increment;\n    }\n\n    case \"End\": {\n      return dial.max();\n    }\n\n    case \"Home\": {\n      return dial.min();\n    }\n\n    case \"PageDown\": {\n      return current - dial.largeStep();\n    }\n\n    case \"PageUp\": {\n      return current + dial.largeStep();\n    }\n\n    default: {\n      return null;\n    }\n  }\n};\n\nconst incrementFor = (\n  event: {\n    altKey: boolean;\n    shiftKey: boolean;\n  },\n  dial: KnobDialContextValue\n): number => {\n  if (event.altKey) {\n    return dial.fineStep();\n  }\n\n  return event.shiftKey ? dial.largeStep() : dial.step();\n};\n\ninterface DragState {\n  angle: number | null;\n  position: number;\n  x: number;\n  y: number;\n}\n\nconst pointerAngle = (\n  event: {\n    clientX: number;\n    clientY: number;\n  },\n  element: HTMLElement\n): number | null => {\n  const rect = element.getBoundingClientRect();\n\n  const x = event.clientX - (rect.left + rect.width / 2);\n\n  const y = event.clientY - (rect.top + rect.height / 2);\n\n  if (Math.hypot(x, y) < (rect.width / 2) * DEAD_ZONE) {\n    return null;\n  }\n\n  return Math.atan2(x, -y) / DEGREES_TO_RADIANS;\n};\n\nconst dragAngle = (\n  event: PointerEvent,\n  element: HTMLDivElement,\n  dial: KnobDialContextValue\n): number | null =>\n  dial.dragDirection() === \"circular\" ? pointerAngle(event, element) : null;\n\nconst turnBetween = (from: number, to: number): number =>\n  ((to - from + HALF_TURN * 3) % (HALF_TURN * 2)) - HALF_TURN;\n\nconst dragPosition = (\n  event: PointerEvent,\n  last: DragState,\n  angle: number | null,\n  dial: KnobDialContextValue,\n  arc: number\n): number => {\n  const fine = event.shiftKey ? FINE_FACTOR : 1;\n\n  if (dial.dragDirection() === \"circular\") {\n    if (last.angle === null || angle === null) {\n      return last.position;\n    }\n\n    const turn = turnBetween(last.angle, angle);\n\n    return clamp(last.position + (turn / arc) * fine, 0, 1);\n  }\n\n  const delta =\n    dial.dragDirection() === \"vertical\"\n      ? last.y - event.clientY\n      : event.clientX - last.x;\n\n  return clamp(last.position + (delta / dial.sensitivity()) * fine, 0, 1);\n};\n\nconst reachesDetent = (\n  detents: readonly number[],\n  from: number,\n  to: number\n): boolean =>\n  detents.some((detent) =>\n    from < to\n      ? detent > from + DETENT_EPSILON && detent <= to + DETENT_EPSILON\n      : detent < from - DETENT_EPSILON && detent >= to - DETENT_EPSILON\n  );\n\nconst reachesMultiple = (\n  from: number,\n  to: number,\n  min: number,\n  every: number\n): boolean => {\n  const start = roundValue((from - min) / every);\n\n  const end = roundValue((to - min) / every);\n\n  return from < to\n    ? Math.floor(end) > Math.floor(start)\n    : Math.ceil(start) > Math.ceil(end);\n};\n\nconst knobVariants = cva(\n  \"group/knob inline-flex flex-col items-center gap-1.5 select-none data-disabled:opacity-50\",\n  {\n    defaultVariants: {\n      size: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"[--knob-size:3rem]\",\n        lg: \"[--knob-size:4rem]\",\n        sm: \"[--knob-size:2.25rem]\",\n      },\n    },\n  }\n);\n\nconst knobCapVariants = cva(\n  \"[--knob-cap-metal:var(--color-white)] [--knob-cap-shade:var(--color-black)]\",\n  {\n    defaultVariants: {\n      variant: \"default\",\n    },\n    variants: {\n      variant: {\n        default: \"[--knob-cap-pitch:0.3px]\",\n        mini: \"[--knob-cap-pitch:0.9px]\",\n      },\n    },\n  }\n);\n\nexport interface KnobProps extends Omit<\n  DivDOMProps,\n  \"children\" | \"class\" | \"className\" | \"onChange\" | \"ref\"\n> {\n  allowWheel?: boolean;\n  arc?: number;\n  children?: DivDOMProps[\"children\"];\n  class?: string;\n  className?: string;\n  clickSound?: boolean | ClickSound;\n  defaultValue?: number;\n  disabled?: boolean;\n  dragDirection?: KnobDragDirection;\n  fineStep?: number;\n  format?: (value: number) => string;\n  largeStep?: number;\n  max?: number;\n  min?: number;\n  onValueChange?: (value: number, details: KnobChangeDetails) => void;\n  onValueCommitted?: (value: number) => void;\n  origin?: number;\n  parse?: (text: string) => number | null;\n  resetValue?: number;\n  scale?: \"linear\" | \"log\";\n  sensitivity?: number;\n  size?: AudioSize;\n  step?: number;\n  value?: number;\n}\n\nconst KNOB_OWN = [\n  \"allowWheel\",\n  \"arc\",\n  \"children\",\n  \"class\",\n  \"className\",\n  \"clickSound\",\n  \"defaultValue\",\n  \"disabled\",\n  \"dragDirection\",\n  \"fineStep\",\n  \"format\",\n  \"largeStep\",\n  \"max\",\n  \"min\",\n  \"onValueChange\",\n  \"onValueCommitted\",\n  \"origin\",\n  \"parse\",\n  \"resetValue\",\n  \"scale\",\n  \"sensitivity\",\n  \"size\",\n  \"step\",\n  \"value\",\n] as const;\n\nexport const Knob = (props: KnobProps) => {\n  const rest = omitProps(props, KNOB_OWN);\n\n  const config = useAudioConfig();\n  const providedAudio = useProvidedAudioContext();\n\n  const labelId = `knob-${createUniqueId()}-label`;\n\n  const min = () => props.min ?? 0;\n  const max = () => props.max ?? 100;\n  const step = () => props.step ?? 1;\n\n  const largeStep = () => props.largeStep ?? 10;\n\n  const fineStep = () => props.fineStep ?? step() * FINE_FACTOR;\n\n  const resetValue = () => props.resetValue ?? props.defaultValue ?? min();\n\n  const originValue = () => clamp(props.origin ?? min(), min(), max());\n\n  const arc = () => props.arc ?? 270;\n\n  const dragDirection = () => props.dragDirection ?? \"vertical\";\n\n  const sensitivity = () => props.sensitivity ?? 200;\n\n  const scale = () => props.scale ?? \"linear\";\n\n  const allowWheel = () => props.allowWheel ?? false;\n\n  const clickSound = () => props.clickSound ?? false;\n\n  const format = () => props.format ?? String;\n\n  const parse = () => props.parse ?? parseKnobValue;\n\n  const size = () => props.size ?? config.size ?? \"default\";\n\n  const disabled = () => props.disabled ?? config.disabled ?? false;\n\n  const [uncontrolled, setUncontrolled] = createSignal(\n    clamp(props.defaultValue ?? resetValue(), min(), max())\n  );\n\n  const value = () => props.value ?? uncontrolled();\n\n  let latestValue = value();\n\n  createCompatEffect(value, (next) => {\n    latestValue = next;\n  });\n\n  const taper = createMemo<Taper>(() =>\n    scale() === \"log\" ? logTaper(min(), max()) : linearTaper(min(), max())\n  );\n\n  const quantize = (next: number, increment: number): number => {\n    const stepped = min() + Math.round((next - min()) / increment) * increment;\n\n    return roundValue(clamp(stepped, min(), max()));\n  };\n\n  const [detents, setDetents] = createSignal<readonly number[] | null>(null);\n\n  const clicksBetween = createMemo<\n    ((from: number, to: number) => boolean) | null\n  >(() => {\n    if (!clickSound()) {\n      return null;\n    }\n\n    const positions = detents();\n\n    if (positions) {\n      return (from: number, to: number) =>\n        reachesDetent(\n          positions,\n          taper().toPosition(from),\n          taper().toPosition(to)\n        );\n    }\n\n    return (from: number, to: number) =>\n      reachesMultiple(from, to, min(), largeStep());\n  });\n\n  const change = (next: number, details: KnobChangeDetails) => {\n    const previous = latestValue;\n\n    if (next === previous) {\n      return;\n    }\n\n    latestValue = next;\n\n    if (props.value === undefined) {\n      setUncontrolled(next);\n    }\n\n    props.onValueChange?.(next, details);\n\n    const configuredClick = clickSound();\n\n    if (clicksBetween()?.(previous, next) && configuredClick) {\n      const sound =\n        configuredClick === true ? DEFAULT_KNOB_CLICK : configuredClick;\n\n      const context = providedAudio.context ?? getSharedAudioContext();\n\n      if (context) {\n        sound.play({\n          context,\n          destination: providedAudio.destination ?? context.destination,\n          resume: () => resumeAudioContext(context),\n        });\n      }\n    }\n\n    if (props.value !== undefined) {\n      queueMicrotask(() => {\n        latestValue = value();\n      });\n    }\n  };\n\n  const commit = (next: number) => {\n    props.onValueCommitted?.(next);\n  };\n\n  const position = () => taper().toPosition(value());\n\n  const originPosition = () => taper().toPosition(originValue());\n\n  const [editing, setEditing] = createSignal(false);\n\n  const valueWidth = createMemo(() =>\n    widestFormattedValue(\n      Array.from({ length: WIDTH_SAMPLES + 1 }, (_, index) =>\n        quantize(taper().toValue(index / WIDTH_SAMPLES), step())\n      ),\n      format()\n    )\n  );\n\n  const context: KnobContextValue = {\n    arc,\n    disabled,\n    editing,\n    format,\n    labelId,\n    originPosition,\n    parse,\n    position,\n    setEditing,\n    value,\n    valueWidth,\n  };\n\n  const dial: KnobDialContextValue = {\n    allowWheel,\n    change,\n    commit,\n    dragDirection,\n    fineStep,\n    largeStep,\n    latest: () => latestValue,\n    max,\n    min,\n    quantize,\n    resetValue,\n    sensitivity,\n    setDetents: (positions) => {\n      setDetents(positions);\n    },\n    step,\n    taper,\n  };\n\n  return provideContext(KnobContext, context, () =>\n    provideContext(KnobDialContext, dial, () => (\n      <div\n        class={cn(\n          knobVariants({\n            size: size(),\n          }),\n          props.class,\n          props.className\n        )}\n        data-at-origin={value() === originValue() ? \"\" : undefined}\n        data-disabled={disabled() ? \"\" : undefined}\n        data-size={size()}\n        data-slot=\"knob\"\n        {...rest}\n      >\n        {props.children ?? (\n          <KnobDial>\n            <KnobTrack />\n            <KnobRange />\n            <KnobPointer />\n          </KnobDial>\n        )}\n      </div>\n    ))\n  );\n};\n\nexport interface KnobDialProps extends Omit<\n  DivDOMProps,\n  \"children\" | \"class\" | \"className\" | \"onDoubleClick\" | \"style\"\n> {\n  children?: DivDOMProps[\"children\"];\n  class?: string;\n  className?: string;\n  onDoubleClick?: (event: MouseEvent) => void;\n  style?: StyleValue;\n}\n\nconst DIAL_OWN = [\n  \"children\",\n  \"class\",\n  \"className\",\n  \"onDoubleClick\",\n  \"style\",\n] as const;\n\nexport const KnobDial = (props: KnobDialProps) => {\n  const context = useKnob(\"KnobDial\");\n\n  const dial = useKnobDial();\n\n  const rest = omitProps(props, DIAL_OWN);\n\n  const [dragging, setDragging] = createSignal(false);\n  const dialAngle = () => angleFor(context.position(), context.arc());\n\n  let dialElement: HTMLDivElement | undefined;\n\n  let drag: DragState | null = null;\n\n  const reset = () => {\n    dial.change(dial.resetValue(), {\n      reason: \"reset\",\n    });\n\n    dial.commit(dial.resetValue());\n  };\n\n  createCompatEffect(\n    () => [dial.allowWheel(), context.disabled()] as const,\n    ([wheel, disabled]) => {\n      const element = dialElement;\n\n      if (!element || !wheel) {\n        return;\n      }\n\n      const listener = (event: WheelEvent) => {\n        const delta = event.deltaY || event.deltaX;\n\n        if (disabled || document.activeElement !== element || delta === 0) {\n          return;\n        }\n\n        event.preventDefault();\n\n        const fine = event.shiftKey || event.altKey;\n\n        const increment = fine ? dial.fineStep() : dial.step();\n\n        const direction = delta < 0 ? 1 : -1;\n\n        const next = dial.quantize(\n          dial.latest() + direction * increment,\n          increment\n        );\n\n        dial.change(next, {\n          event,\n          reason: \"wheel\",\n        });\n\n        dial.commit(next);\n      };\n\n      element.addEventListener(\"wheel\", listener, {\n        passive: false,\n      });\n\n      return () => {\n        element.removeEventListener(\"wheel\", listener);\n      };\n    }\n  );\n\n  const endDrag = (event: PointerEvent) => {\n    const target = event.currentTarget;\n\n    if (!drag || !(target instanceof HTMLDivElement)) {\n      return;\n    }\n\n    drag = null;\n    setDragging(false);\n\n    if (target.hasPointerCapture(event.pointerId)) {\n      target.releasePointerCapture(event.pointerId);\n    }\n\n    dial.commit(dial.latest());\n  };\n\n  return (\n    <div\n      aria-disabled={context.disabled() ? \"true\" : undefined}\n      aria-labelledby={context.labelId}\n      aria-valuemax={dial.max()}\n      aria-valuemin={dial.min()}\n      aria-valuenow={context.value()}\n      aria-valuetext={context.format()(context.value())}\n      class={cn(\n        \"group/knob-dial pointer-events-none relative size-(--knob-size) touch-none rounded-full outline-none data-dragging:pointer-events-auto\",\n        {\n          \"data-dragging:cursor-ew-resize\":\n            dial.dragDirection() === \"horizontal\",\n          \"data-dragging:cursor-grabbing\": dial.dragDirection() === \"circular\",\n          \"data-dragging:cursor-ns-resize\": dial.dragDirection() === \"vertical\",\n        },\n        props.class,\n        props.className\n      )}\n      data-dragging={dragging() ? \"\" : undefined}\n      data-slot=\"knob-dial\"\n      onDblClick={(event: MouseEvent) => {\n        props.onDoubleClick?.(event);\n\n        if (!context.disabled()) {\n          reset();\n        }\n      }}\n      onKeyDown={(event: KeyboardEvent) => {\n        if (context.disabled()) {\n          return;\n        }\n\n        if (event.key === \"Enter\") {\n          event.preventDefault();\n          context.setEditing(true);\n\n          return;\n        }\n\n        const increment = incrementFor(event, dial);\n\n        const next = keyTarget(event.key, dial.latest(), increment, dial);\n\n        if (next === null) {\n          return;\n        }\n\n        event.preventDefault();\n\n        const quantized = dial.quantize(next, Math.min(increment, dial.step()));\n\n        dial.change(quantized, {\n          event,\n          reason: \"keyboard\",\n        });\n\n        dial.commit(quantized);\n      }}\n      onLostPointerCapture={endDrag}\n      onPointerDown={(event: PointerEvent) => {\n        const target = event.currentTarget;\n\n        if (\n          context.disabled() ||\n          event.button !== 0 ||\n          !(target instanceof HTMLDivElement)\n        ) {\n          return;\n        }\n\n        if (event.altKey) {\n          event.preventDefault();\n          reset();\n\n          return;\n        }\n\n        target.setPointerCapture(event.pointerId);\n\n        target.focus();\n\n        drag = {\n          angle: dragAngle(event, target, dial),\n          position: dial.taper().toPosition(dial.latest()),\n          x: event.clientX,\n          y: event.clientY,\n        };\n\n        setDragging(true);\n      }}\n      onPointerMove={(event: PointerEvent) => {\n        const target = event.currentTarget;\n\n        const last = drag;\n\n        if (!last || !(target instanceof HTMLDivElement)) {\n          return;\n        }\n\n        const angle = dragAngle(event, target, dial);\n\n        const next = dragPosition(event, last, angle, dial, context.arc());\n\n        drag = {\n          angle,\n          position: next,\n          x: event.clientX,\n          y: event.clientY,\n        };\n\n        const increment = event.shiftKey ? dial.fineStep() : dial.step();\n\n        dial.change(dial.quantize(dial.taper().toValue(next), increment), {\n          event,\n          reason: \"drag\",\n        });\n      }}\n      onPointerUp={endDrag}\n      ref={(node) => {\n        dialElement = node;\n      }}\n      role=\"slider\"\n      style={mergeStyleVars(props.style, {\n        \"--knob-angle\": `${dialAngle()}deg`,\n      })}\n      tabindex={context.disabled() ? -1 : 0}\n      {...rest}\n    >\n      <svg\n        aria-hidden=\"true\"\n        class=\"size-full overflow-visible\"\n        viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}\n      >\n        {provideContext(\n          DialContext,\n          { angle: dialAngle },\n          () => props.children\n        )}\n        <circle\n          class=\"[pointer-events:all] cursor-grab touch-none group-aria-disabled/knob-dial:cursor-default group-data-dragging/knob-dial:cursor-[inherit]\"\n          cx={CENTER}\n          cy={CENTER}\n          data-slot=\"knob-hit-area\"\n          fill=\"none\"\n          r={CENTER}\n        />\n      </svg>\n    </div>\n  );\n};\n\nexport interface KnobTrackProps extends Omit<\n  PathDOMProps,\n  \"class\" | \"className\"\n> {\n  class?: string;\n  className?: string;\n}\n\nconst TRACK_OWN = [\"class\", \"className\"] as const;\n\nexport const KnobTrack = (props: KnobTrackProps) => {\n  const context = useKnob(\"KnobTrack\");\n\n  const rest = omitProps(props, TRACK_OWN);\n\n  return (\n    <path\n      class={cn(\"stroke-input\", props.class, props.className)}\n      d={arcPath(-context.arc() / 2, context.arc() / 2)}\n      data-slot=\"knob-track\"\n      fill=\"none\"\n      stroke-linecap=\"round\"\n      stroke-width={8}\n      {...rest}\n    />\n  );\n};\n\nexport interface KnobRangeProps extends Omit<\n  PathDOMProps,\n  \"class\" | \"className\"\n> {\n  class?: string;\n  className?: string;\n}\n\nconst RANGE_OWN = [\"class\", \"className\"] as const;\n\nexport const KnobRange = (props: KnobRangeProps) => {\n  const context = useKnob(\"KnobRange\");\n\n  const rest = omitProps(props, RANGE_OWN);\n\n  return (\n    <path\n      class={cn(\"stroke-primary\", props.class, props.className)}\n      d={arcPath(\n        angleFor(context.originPosition(), context.arc()),\n        angleFor(context.position(), context.arc())\n      )}\n      data-slot=\"knob-range\"\n      fill=\"none\"\n      stroke-linecap=\"round\"\n      stroke-width={8}\n      {...rest}\n    />\n  );\n};\n\nexport interface KnobPointerProps extends Omit<\n  LineDOMProps,\n  \"class\" | \"className\"\n> {\n  class?: string;\n  className?: string;\n}\n\nconst POINTER_OWN = [\"class\", \"className\"] as const;\n\nexport const KnobPointer = (props: KnobPointerProps) => {\n  const context = useDial(\"KnobPointer\");\n\n  const rest = omitProps(props, POINTER_OWN);\n\n  const angle = context.angle;\n\n  const inner = () => pointAt(angle(), RADIUS * 0.3);\n\n  const outer = () => pointAt(angle(), RADIUS * 0.72);\n\n  return (\n    <>\n      <circle\n        class=\"fill-muted stroke-border\"\n        cx={CENTER}\n        cy={CENTER}\n        r={RADIUS * 0.8}\n        stroke-width={1}\n      />\n\n      <line\n        class={cn(\"stroke-foreground\", props.class, props.className)}\n        data-slot=\"knob-pointer\"\n        stroke-linecap=\"round\"\n        stroke-width={6}\n        x1={inner().x}\n        x2={outer().x}\n        y1={inner().y}\n        y2={outer().y}\n        {...rest}\n      />\n    </>\n  );\n};\n\nexport interface KnobScaleProps extends Omit<\n  GroupDOMProps,\n  \"class\" | \"className\"\n> {\n  class?: string;\n  className?: string;\n  format?: (value: number) => string;\n  labelEvery?: number;\n  majorEvery?: number;\n  ticks?: number;\n}\n\nconst SCALE_OWN = [\n  \"class\",\n  \"className\",\n  \"format\",\n  \"labelEvery\",\n  \"majorEvery\",\n  \"ticks\",\n] as const;\n\nexport const KnobScale = (props: KnobScaleProps) => {\n  const context = useKnob(\"KnobScale\");\n\n  const dial = useKnobDial();\n\n  const rest = omitProps(props, SCALE_OWN);\n\n  const count = () => Math.max(1, Math.round(props.ticks ?? 50));\n\n  const majorStep = () => Math.max(1, Math.round(props.majorEvery ?? 5));\n\n  const labelEvery = () => props.labelEvery ?? 10;\n\n  const formatLabel = () => props.format ?? context.format();\n\n  createCompatEffect(\n    () => [count(), majorStep()] as const,\n    ([currentCount, currentMajor]) => {\n      const majors: number[] = [];\n\n      for (let index = 0; index <= currentCount; index += currentMajor) {\n        majors.push(index / currentCount);\n      }\n\n      dial.setDetents(majors);\n\n      return () => {\n        dial.setDetents(null);\n      };\n    }\n  );\n\n  const litFrom = () =>\n    Math.min(context.originPosition(), context.position()) - TICK_EPSILON;\n\n  const litTo = () =>\n    Math.max(context.originPosition(), context.position()) + TICK_EPSILON;\n\n  const marks = createMemo(() =>\n    Array.from(\n      {\n        length: count() + 1,\n      },\n      (_, index) => {\n        const tickPosition = index / count();\n\n        const angle = angleFor(tickPosition, context.arc());\n\n        const major = index % majorStep() === 0;\n\n        const inner = pointAt(\n          angle,\n          major ? SCALE.majorInner : SCALE.minorInner\n        );\n\n        const outer = pointAt(\n          angle,\n          major ? SCALE.majorOuter : SCALE.minorOuter\n        );\n\n        return {\n          index,\n          inner,\n          major,\n          outer,\n          tickPosition,\n        };\n      }\n    )\n  );\n\n  const labels = createMemo(() => {\n    const every = labelEvery();\n\n    if (every <= 0) {\n      return [];\n    }\n\n    return Array.from(\n      {\n        length: Math.floor(count() / every) + 1,\n      },\n      (_, index) => {\n        const tickPosition = (index * every) / count();\n\n        const angle = angleFor(tickPosition, context.arc());\n\n        const point = pointAt(angle, SCALE.label);\n\n        return {\n          angle,\n          point,\n          tickPosition,\n        };\n      }\n    );\n  });\n\n  return (\n    <g\n      class={cn(props.class, props.className)}\n      data-slot=\"knob-scale\"\n      {...rest}\n    >\n      <For each={marks()}>\n        {(mark) => {\n          const lit = () =>\n            mark.tickPosition >= litFrom() && mark.tickPosition <= litTo();\n\n          return (\n            <line\n              class=\"stroke-muted-foreground/45 data-active:stroke-foreground data-major:stroke-muted-foreground data-major:data-active:stroke-foreground\"\n              data-active={lit() ? \"\" : undefined}\n              data-major={mark.major ? \"\" : undefined}\n              data-slot=\"knob-tick\"\n              stroke-width={mark.major ? 1.1 : 0.55}\n              x1={mark.inner.x}\n              x2={mark.outer.x}\n              y1={mark.inner.y}\n              y2={mark.outer.y}\n            />\n          );\n        }}\n      </For>\n\n      <For each={labels()}>\n        {(label) => (\n          <text\n            class=\"fill-muted-foreground\"\n            data-slot=\"knob-scale-label\"\n            dominant-baseline=\"central\"\n            font-size={String(SCALE.labelSize)}\n            text-anchor=\"middle\"\n            transform={`rotate(${label.angle} ${label.point.x} ${label.point.y})`}\n            x={label.point.x}\n            y={label.point.y}\n          >\n            {formatLabel()(\n              roundValue(dial.taper().toValue(label.tickPosition))\n            )}\n          </text>\n        )}\n      </For>\n    </g>\n  );\n};\n\ntype KnobCapVariant = \"default\" | \"mini\";\n\nconst KnobCapIndicator = (props: {\n  angle: () => number;\n  variant: () => KnobCapVariant;\n}) => {\n  const dot = () => pointAt(props.angle(), CAP_DOT.distance);\n\n  const inner = () => pointAt(props.angle(), CAP_LINE.inner);\n\n  const outer = () => pointAt(props.angle(), CAP_LINE.outer);\n\n  return (\n    <Show\n      fallback={\n        <>\n          <line\n            class=\"stroke-(--knob-cap-metal)/70\"\n            stroke-linecap=\"round\"\n            stroke-width={CAP_LINE.width}\n            x1={inner().x}\n            x2={outer().x}\n            y1={inner().y + CAP_LINE.lip}\n            y2={outer().y + CAP_LINE.lip}\n          />\n\n          <line\n            class=\"stroke-(--knob-cap-shade)/80\"\n            data-slot=\"knob-cap-pointer\"\n            stroke-linecap=\"round\"\n            stroke-width={CAP_LINE.width}\n            x1={inner().x}\n            x2={outer().x}\n            y1={inner().y}\n            y2={outer().y}\n          />\n        </>\n      }\n      when={props.variant() === \"default\"}\n    >\n      <circle\n        class=\"fill-(--knob-cap-shade)/85 stroke-(--knob-cap-shade)/45\"\n        cx={dot().x}\n        cy={dot().y}\n        data-slot=\"knob-cap-dot\"\n        r={CAP_DOT.radius}\n        stroke-width={0.35}\n      />\n    </Show>\n  );\n};\n\nexport interface KnobCapProps extends Omit<\n  GroupDOMProps,\n  \"class\" | \"className\"\n> {\n  class?: string;\n  className?: string;\n  variant?: KnobCapVariant;\n}\n\nconst CAP_OWN = [\"class\", \"className\", \"variant\"] as const;\n\nexport const KnobCap = (props: KnobCapProps) => {\n  const context = useDial(\"KnobCap\");\n\n  const rest = omitProps(props, CAP_OWN);\n\n  const variant = () => props.variant ?? \"default\";\n\n  const id = `knob${createUniqueId().replaceAll(/[^\\w-]/gu, \"\")}`;\n\n  const angle = context.angle;\n\n  const cap = () => CAP[variant()];\n\n  createCompatEffect(\n    () => [cap().bezel, context.onCapRadiusChange] as const,\n    ([radius, onCapRadiusChange]) => {\n      onCapRadiusChange?.(radius);\n\n      return () => onCapRadiusChange?.(null);\n    }\n  );\n\n  return (\n    <g\n      class={cn(\n        knobCapVariants({\n          variant: variant(),\n        }),\n        props.class,\n        props.className\n      )}\n      data-slot=\"knob-cap\"\n      data-variant={variant()}\n      {...rest}\n    >\n      <defs>\n        <filter height=\"100%\" id={`${id}-grain`} width=\"100%\" x=\"0\" y=\"0\">\n          <feTurbulence\n            baseFrequency={0.9}\n            numOctaves={3}\n            seed={7}\n            type=\"fractalNoise\"\n          />\n\n          <feColorMatrix type=\"saturate\" values=\"0\" />\n\n          <feComposite in2=\"SourceGraphic\" operator=\"in\" />\n        </filter>\n\n        <radialGradient id={`${id}-halo`}>\n          <stop\n            class=\"[stop-color:var(--knob-cap-shade)]\"\n            offset={cap().haloFrom}\n            stop-opacity={0.55}\n          />\n\n          <stop\n            class=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"1\"\n            stop-opacity={0}\n          />\n        </radialGradient>\n\n        <linearGradient id={`${id}-bezel`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop\n            class=\"[stop-color:var(--knob-cap-metal)]\"\n            offset=\"0\"\n            stop-opacity={0.16}\n          />\n\n          <stop\n            class=\"[stop-color:var(--knob-cap-metal)]\"\n            offset=\"1\"\n            stop-opacity={0}\n          />\n        </linearGradient>\n\n        <linearGradient id={`${id}-rim`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop\n            class=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"0\"\n            stop-opacity={0.62}\n          />\n\n          <stop\n            class=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"1\"\n            stop-opacity={0.08}\n          />\n        </linearGradient>\n      </defs>\n\n      <circle\n        cx={CENTER}\n        cy={CENTER}\n        fill={`url(#${id}-halo)`}\n        r={cap().halo}\n      />\n\n      <circle\n        class=\"fill-(--knob-cap-shade)/85 stroke-(--knob-cap-metal)/15\"\n        cx={CENTER}\n        cy={CENTER}\n        r={cap().bezel}\n        stroke-width={0.4}\n      />\n\n      <circle\n        cx={CENTER}\n        cy={CENTER}\n        fill={`url(#${id}-bezel)`}\n        r={cap().bezel}\n      />\n\n      <circle\n        class=\"fill-(--knob-cap-metal)\"\n        cx={CENTER}\n        cy={CENTER}\n        r={cap().rim}\n      />\n\n      <circle cx={CENTER} cy={CENTER} fill={`url(#${id}-rim)`} r={cap().rim} />\n\n      <foreignObject\n        height={cap().face * 2}\n        width={cap().face * 2}\n        x={CENTER - cap().face}\n        y={CENTER - cap().face}\n      >\n        <div\n          class=\"size-full rounded-full bg-(--knob-cap-metal) bg-[repeating-radial-gradient(circle,var(--knob-cap-brush)_0_var(--knob-cap-pitch),transparent_var(--knob-cap-pitch)_calc(var(--knob-cap-pitch)*2)),conic-gradient(from_15deg,var(--knob-cap-sheen),transparent_9%,var(--knob-cap-sheen)_21%,transparent_32%,var(--knob-cap-sheen-deep)_46%,transparent_58%,var(--knob-cap-sheen)_70%,transparent_83%,var(--knob-cap-sheen))] [--knob-cap-brush:color-mix(in_oklab,var(--knob-cap-shade)_7%,transparent)] [--knob-cap-sheen-deep:color-mix(in_oklab,var(--knob-cap-shade)_50%,transparent)] [--knob-cap-sheen:color-mix(in_oklab,var(--knob-cap-shade)_34%,transparent)]\"\n          data-slot=\"knob-cap-face\"\n        />\n      </foreignObject>\n\n      <circle\n        cx={CENTER}\n        cy={CENTER}\n        data-slot=\"knob-cap-grain\"\n        filter={`url(#${id}-grain)`}\n        opacity={0.25}\n        pointer-events=\"none\"\n        r={cap().face}\n        transform={`rotate(${angle()} ${CENTER} ${CENTER})`}\n      />\n\n      <KnobCapIndicator angle={angle} variant={variant} />\n    </g>\n  );\n};\n\nconst focusDial = (from: HTMLElement): void => {\n  const root = from.closest(\"[data-slot='knob']\");\n\n  queueMicrotask(() => {\n    root?.querySelector<HTMLElement>(\"[data-slot='knob-dial']\")?.focus();\n  });\n};\n\ninterface KnobValueInputProps {\n  class?: string;\n  className?: string;\n  style?: StyleValue;\n}\n\nconst KnobValueInput = (props: KnobValueInputProps) => {\n  const context = useKnob(\"KnobValue\");\n\n  const dial = useKnobDial();\n\n  const [draft, setDraft] = createSignal(context.format()(context.value()));\n\n  let done = false;\n\n  const finish = (apply: boolean) => {\n    if (done) {\n      return;\n    }\n\n    done = true;\n    context.setEditing(false);\n\n    const parsed = apply ? context.parse()(draft()) : null;\n\n    if (parsed === null || Number.isNaN(parsed)) {\n      return;\n    }\n\n    const next = dial.quantize(\n      clamp(parsed, dial.min(), dial.max()),\n      Math.min(dial.fineStep(), dial.step())\n    );\n\n    dial.change(next, {\n      reason: \"input\",\n    });\n\n    dial.commit(next);\n  };\n\n  return (\n    <input\n      aria-label=\"Value\"\n      class={cn(\n        \"bg-background ring-ring/50 focus-visible:ring-foreground h-4 w-(--knob-value-width) min-w-0 rounded-sm p-0 text-center font-mono text-xs tabular-nums ring-1 outline-none focus-visible:ring-2\",\n        props.class,\n        props.className\n      )}\n      data-slot=\"knob-value-input\"\n      onBlur={() => {\n        finish(true);\n      }}\n      onInput={(event) => {\n        setDraft(event.currentTarget.value);\n      }}\n      onKeyDown={(event) => {\n        if (event.key !== \"Enter\" && event.key !== \"Escape\") {\n          return;\n        }\n\n        event.preventDefault();\n\n        const input = event.currentTarget;\n\n        focusDial(input);\n\n        finish(event.key === \"Enter\");\n      }}\n      ref={(node) => {\n        queueMicrotask(() => {\n          node.focus();\n          node.select();\n        });\n      }}\n      style={props.style}\n      value={draft()}\n    />\n  );\n};\n\nexport interface KnobValueProps extends Omit<\n  SpanDOMProps,\n  \"children\" | \"class\" | \"className\" | \"onDoubleClick\" | \"style\"\n> {\n  class?: string;\n  className?: string;\n  editable?: boolean;\n  onDoubleClick?: (event: MouseEvent) => void;\n  style?: StyleValue;\n}\n\nconst VALUE_OWN = [\n  \"class\",\n  \"className\",\n  \"editable\",\n  \"onDoubleClick\",\n  \"style\",\n] as const;\n\nexport const KnobValue = (props: KnobValueProps) => {\n  const context = useKnob(\"KnobValue\");\n\n  const rest = omitProps(props, VALUE_OWN);\n\n  const editable = () => props.editable ?? true;\n\n  const widthStyle = () =>\n    mergeStyleVars(props.style, {\n      \"--knob-value-width\": `${context.valueWidth()}ch`,\n    });\n\n  return (\n    <Show\n      fallback={\n        <span\n          class={cn(\n            \"text-muted-foreground inline-block min-w-(--knob-value-width) text-center font-mono text-xs whitespace-nowrap tabular-nums\",\n            editable() && !context.disabled() ? \"cursor-text\" : undefined,\n            props.class,\n            props.className\n          )}\n          data-slot=\"knob-value\"\n          onDblClick={(event: MouseEvent) => {\n            props.onDoubleClick?.(event);\n\n            if (editable() && !context.disabled()) {\n              context.setEditing(true);\n            }\n          }}\n          style={widthStyle()}\n          {...rest}\n        >\n          {context.format()(context.value())}\n        </span>\n      }\n      when={editable() && context.editing()}\n    >\n      <KnobValueInput\n        class={props.class}\n        className={props.className}\n        style={widthStyle()}\n      />\n    </Show>\n  );\n};\n\nexport interface KnobLabelProps extends Omit<\n  SpanDOMProps,\n  \"children\" | \"class\" | \"className\" | \"onDoubleClick\"\n> {\n  children?: SpanDOMProps[\"children\"];\n  class?: string;\n  className?: string;\n  onDoubleClick?: (event: MouseEvent) => void;\n}\n\nconst LABEL_OWN = [\n  \"children\",\n  \"class\",\n  \"className\",\n  \"id\",\n  \"onDoubleClick\",\n] as const;\n\nexport const KnobLabel = (props: KnobLabelProps) => {\n  const context = useKnob(\"KnobLabel\");\n\n  const rest = omitProps(props, LABEL_OWN);\n\n  return (\n    <span\n      class={cn(\"text-xs font-medium\", props.class, props.className)}\n      data-slot=\"knob-label\"\n      id={context.labelId}\n      onDblClick={(event: MouseEvent) => {\n        props.onDoubleClick?.(event);\n\n        if (!context.disabled()) {\n          context.setEditing(true);\n        }\n      }}\n      {...rest}\n    >\n      {props.children}\n    </span>\n  );\n};\n\nexport { knobCapVariants, knobVariants };\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"
}
