{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fader",
  "title": "Fader",
  "description": "A mixing-console-style parameter fader where the control IS the display, with continuous and detent value grammars, elastic overdrag, and spring-animated settle.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/fader/fader.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { Slider as BaseSlider } from '@base-ui/react/slider'\nimport { type MotionValue, motion, useTransform } from 'motion/react'\nimport type { Ref } from 'react'\nimport { cn } from '@/lib/utils'\nimport { springs } from '@/lib/motion-tokens'\nimport { BAR_BOX, barCenterFor, fillEdgePx } from './geometry'\nimport { type UseFaderOptions, useFader } from './use-fader'\nimport { playHoverSound, playClickSound, playTickSound } from '@/lib/sound'\n\nconst MARK_HIDE_RADIUS = 10\nconst MARK_HIDE_FADE = 6\n\nconst sizeVariants = {\n  sm: {\n    control: 'h-8',\n    overlay: 'px-3',\n    text: 'text-xs',\n    bar: 'h-4',\n    mark: 'size-1',\n  },\n  md: {\n    control: 'h-10',\n    overlay: 'px-3.5',\n    text: 'text-sm',\n    bar: 'h-5',\n    mark: 'size-1',\n  },\n  lg: {\n    control: 'h-12',\n    overlay: 'px-4',\n    text: 'text-sm',\n    bar: 'h-6',\n    mark: 'size-1.5',\n  },\n} as const\n\nconst toneVariants = {\n  accent: {\n    fill: 'border border-muted-foreground/30 bg-muted-foreground/40 pointer-fine:group-hover/fader:bg-muted-foreground/50 group-data-dragging/control:bg-muted-foreground/55',\n    bar: 'bg-muted-foreground',\n  },\n  neutral: {\n    fill: 'border border-muted-foreground/25 bg-muted-foreground/35 pointer-fine:group-hover/fader:bg-muted-foreground/45 group-data-dragging/control:bg-muted-foreground/50',\n    bar: 'bg-muted-foreground/90',\n  },\n} as const\n\nfunction DetentMark({\n  pct,\n  fill,\n  trackWidth,\n  className,\n}: {\n  pct: number\n  fill: MotionValue<number>\n  trackWidth: MotionValue<number>\n  className: string\n}) {\n  const filledOpacity = useTransform(() => {\n    const tw = trackWidth.get()\n    if (!tw) return fill.get() >= pct ? 1 : 0\n    const covered = fillEdgePx(fill.get(), tw) - (pct / 100) * tw\n    return Math.min(1, Math.max(0, covered / 8))\n  })\n  const visibility = useTransform(() => {\n    const tw = trackWidth.get()\n    if (!tw) {\n      return Math.abs(fill.get() - pct) < 0.001 ? 0 : 1\n    }\n    const markPx = (pct / 100) * tw\n    const barCenter = barCenterFor(fillEdgePx(fill.get(), tw))\n    const distance = Math.abs(markPx - barCenter)\n    return Math.min(1, Math.max(0, (distance - MARK_HIDE_RADIUS) / MARK_HIDE_FADE))\n  })\n  return (\n    <motion.span\n      aria-hidden\n      className={cn('-translate-x-1/2 -translate-y-1/2 absolute top-1/2', className)}\n      style={{ left: `${pct}%`, opacity: visibility }}\n    >\n      <span className=\"absolute inset-0 round bg-foreground/25\" />\n      <motion.span\n        className=\"absolute inset-0 round bg-foreground/60\"\n        style={{ opacity: filledOpacity }}\n      />\n    </motion.span>\n  )\n}\n\nexport interface FaderProps extends Omit<UseFaderOptions, 'remeasureKey'> {\n  size?: keyof typeof sizeVariants\n  tone?: keyof typeof toneVariants\n  bordered?: boolean\n  className?: string\n  ref?: Ref<HTMLDivElement>\n}\n\nexport function Fader({\n  size = 'md',\n  tone = 'accent',\n  bordered = false,\n  className,\n  ref,\n  ...behavior\n}: FaderProps) {\n  const slider = useFader({ ...behavior, remeasureKey: size })\n  const sizeStyle = sizeVariants[size]\n  const toneStyle = toneVariants[tone]\n\n  return (\n    <BaseSlider.Root\n      ref={ref}\n      // eslint-disable-next-line react-hooks/refs\n      {...slider.rootProps}\n      className={cn('w-full data-disabled:opacity-45', className)}\n    >\n      <div className=\"group/fader relative w-full\">\n        <BaseSlider.Control\n          {...slider.controlProps}\n          onMouseEnter={() => playHoverSound()}\n          onPointerDown={(e) => {\n            playClickSound()\n            slider.controlProps?.onPointerDown?.(e)\n          }}\n          className={cn(\n            'group/control relative block w-full cursor-grab touch-pan-y select-none rounded-md outline-none data-disabled:cursor-default data-dragging:cursor-grabbing has-[&:focus-visible]:ring-2 has-[&:focus-visible]:ring-ring has-[&:focus-visible]:ring-offset-2 has-[&:focus-visible]:ring-offset-background bg-muted',\n            sizeStyle.control,\n          )}\n        >\n          <BaseSlider.Track\n            render={\n              <motion.div\n                style={{\n                  scaleX: slider.trackScaleX,\n                  transformOrigin: slider.trackOrigin,\n                }}\n              />\n            }\n            className={cn(\n              'h-full w-full overflow-hidden rounded-md bg-muted border border-border/50',\n              bordered && '!border-border',\n            )}\n          >\n            <motion.div\n              className={cn(\n                'absolute top-0 left-0 h-full rounded-md transition-colors duration-(--motion-dur-fast) ease-(--motion-ease-out)',\n                toneStyle.fill,\n              )}\n              style={{ width: slider.fillWidth }}\n            >\n              <div\n                className=\"ml-auto flex h-full items-center justify-center\"\n                style={{ width: BAR_BOX }}\n              >\n                <motion.span\n                  aria-hidden\n                  className={cn('w-1 rounded-full', sizeStyle.bar, toneStyle.bar)}\n                  initial={false}\n                  animate={\n                    slider.dodge\n                      ? {\n                          opacity: 0.3,\n                          scaleY: slider.reducedMotion ? 1 : 0.75,\n                        }\n                      : slider.grabbed\n                        ? {\n                            opacity: 1,\n                            scaleY: slider.reducedMotion ? 1 : 1.2,\n                          }\n                        : slider.focusVisible\n                          ? {\n                              opacity: 1,\n                              scaleY: slider.reducedMotion ? 1 : 1.35,\n                            }\n                          : { opacity: 0.85, scaleY: 1 }\n                  }\n                  transition={springs.settle}\n                />\n              </div>\n            </motion.div>\n            <div className=\"pointer-events-none absolute inset-0\">\n              {slider.markPercents.map((pct) => (\n                <DetentMark\n                  key={pct}\n                  pct={pct}\n                  fill={slider.fillPercent}\n                  trackWidth={slider.trackWidth}\n                  className={sizeStyle.mark}\n                />\n              ))}\n            </div>\n            <BaseSlider.Thumb {...slider.thumbProps} className=\"h-8 w-5 outline-none\" />\n          </BaseSlider.Track>\n        </BaseSlider.Control>\n\n        <div\n          aria-hidden\n          className={cn(\n            'pointer-events-none absolute inset-0 flex items-center justify-between',\n            sizeStyle.overlay,\n          )}\n        >\n          <BaseSlider.Label\n            ref={slider.labelRef}\n            className={cn('font-medium text-foreground', sizeStyle.text)}\n          >\n            {behavior.label}\n          </BaseSlider.Label>\n          <BaseSlider.Value\n            // eslint-disable-next-line react-hooks/refs\n            ref={slider.valueRef}\n            className={cn('text-foreground tabular-nums', sizeStyle.text)}\n          >\n            {(parts) => (\n              <>\n                {parts[0]}\n                {behavior.unit ? (\n                  <span className=\"text-muted-foreground\">{behavior.unit}</span>\n                ) : null}\n              </>\n            )}\n          </BaseSlider.Value>\n        </div>\n        {/* /group/fader */}\n      </div>\n    </BaseSlider.Root>\n  )\n}\n"
    },
    {
      "path": "components/ui/fader/geometry.ts",
      "type": "registry:ui",
      "content": "export const BAR_BOX = 16\n\nexport function fillEdgePx(percent: number, trackWidth: number): number {\n  return Math.min(Math.max(0, (percent / 100) * trackWidth), trackWidth)\n}\n\nexport function barCenterFor(fillEdge: number): number {\n  return fillEdge >= BAR_BOX ? fillEdge - BAR_BOX / 2 : BAR_BOX / 2\n}\n"
    },
    {
      "path": "components/ui/fader/use-fader.ts",
      "type": "registry:ui",
      "content": "'use client'\n\nimport type { Slider as BaseSlider } from '@base-ui/react/slider'\nimport {\n  animate,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from 'motion/react'\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { useElasticOverdrag } from '@/hooks/use-elastic-overdrag'\nimport { springs } from '@/lib/motion-tokens'\nimport { barCenterFor, fillEdgePx } from './geometry'\nimport { playTickSound } from '@/lib/sound'\n\nconst DISCRETE_LIMIT = 12\nconst DODGE_ZONE = 6\nconst LARGE_STEP_FRACTION = 4\n\nfunction decimalsFor(step: number): number {\n  const fraction = step.toString().split('.')[1]\n  return Math.min(3, fraction?.length ?? 0)\n}\n\nfunction nearestIndex(points: number[], value: number): number {\n  let best = 0\n  for (let i = 1; i < points.length; i++) {\n    if (Math.abs(points[i] - value) < Math.abs(points[best] - value)) best = i\n  }\n  return best\n}\n\nexport interface UseFaderOptions {\n  label: string\n  value: number\n  onValueChange: (value: number) => void\n  min?: number\n  max?: number\n  step?: number\n  unit?: string\n  points?: number[]\n  disabled?: boolean\n  remeasureKey?: unknown\n}\n\nconst formatterCache = new Map<number, Intl.NumberFormat>()\nfunction formatterFor(decimals: number): Intl.NumberFormat {\n  let formatter = formatterCache.get(decimals)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(undefined, {\n      minimumFractionDigits: decimals,\n      maximumFractionDigits: decimals,\n    })\n    formatterCache.set(decimals, formatter)\n  }\n  return formatter\n}\n\nexport function useFader(options: UseFaderOptions) {\n  const {\n    label,\n    value,\n    onValueChange,\n    min = 0,\n    max = 100,\n    step = 1,\n    unit,\n    points,\n    disabled = false,\n    remeasureKey,\n  } = options\n\n  const controlRef = useRef<HTMLDivElement>(null)\n  const labelRef = useRef<HTMLDivElement>(null)\n  const valueRef = useRef<HTMLOutputElement>(null)\n  const settleRef = useRef<ReturnType<typeof animate> | null>(null)\n  const settleTargetRef = useRef<number | null>(null)\n  const percentRef = useRef(toPercent(value))\n  const reconcileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const zonesRef = useRef({\n    labelStart: 0,\n    labelEnd: 0,\n    valueStart: Number.POSITIVE_INFINITY,\n  })\n\n  const [focusVisible, setFocusVisible] = useState(false)\n  const [dodge, setDodge] = useState(false)\n  const reducedMotion = useReducedMotion()\n\n  const overdrag = useElasticOverdrag({\n    disabled,\n    reduceMotion: reducedMotion ?? false,\n  })\n\n  const sortedPoints = points ? [...points].sort((a, b) => a - b) : null\n  const decimals = decimalsFor(step)\n  const formatted = formatterFor(decimals).format(value)\n\n  function toPercent(v: number): number {\n    if (max <= min) return 0\n    return ((v - min) / (max - min)) * 100\n  }\n  const percent = toPercent(value)\n  useEffect(() => {\n    percentRef.current = percent\n  }, [percent])\n\n  const stepRatio = (max - min) / step\n  const fullSteps = Math.floor(stepRatio + 1e-9)\n  const stops = fullSteps + (stepRatio - fullSteps > 1e-9 ? 2 : 1)\n  const isSnappy = sortedPoints !== null || stops <= DISCRETE_LIMIT\n\n  let markCandidates: number[] = []\n  if (sortedPoints) {\n    markCandidates = sortedPoints.map(toPercent)\n  } else if (isSnappy) {\n    markCandidates = Array.from({ length: fullSteps }, (_, i) => toPercent(min + (i + 1) * step))\n  }\n  const markPercents = markCandidates.filter((pct) => pct > 0.5 && pct < 99.5)\n\n  const fillPercent = useMotionValue(percent)\n  const trackWidth = useMotionValue(0)\n  const fillWidth = useTransform(() => {\n    const tw = trackWidth.get()\n    if (!tw) return `${Math.min(Math.max(fillPercent.get(), 0), 100)}%`\n    return fillEdgePx(fillPercent.get(), tw)\n  })\n\n  function updateDodge() {\n    const tw = trackWidth.get()\n    if (!tw) return\n    const zones = zonesRef.current\n    const barCenter = barCenterFor(fillEdgePx(fillPercent.get(), tw))\n    setDodge(\n      (barCenter > zones.labelStart - DODGE_ZONE && barCenter < zones.labelEnd + DODGE_ZONE) ||\n        barCenter > zones.valueStart - DODGE_ZONE,\n    )\n  }\n  useMotionValueEvent(fillPercent, 'change', updateDodge)\n\n  useEffect(() => {\n    if (settleRef.current && settleTargetRef.current === percent) return\n    settleRef.current?.stop()\n    settleRef.current = null\n    settleTargetRef.current = null\n    fillPercent.set(percent)\n  }, [percent, fillPercent])\n\n  useEffect(\n    () => () => {\n      settleRef.current?.stop()\n      if (reconcileTimeoutRef.current !== null) {\n        clearTimeout(reconcileTimeoutRef.current)\n      }\n    },\n    [],\n  )\n\n  useEffect(() => {\n    if (overdrag.dragging) return\n    reconcileTimeoutRef.current = setTimeout(() => {\n      reconcileTimeoutRef.current = null\n      if (settleRef.current) return\n      if (fillPercent.get() !== percentRef.current) {\n        fillPercent.set(percentRef.current)\n      }\n    }, 300)\n    return () => {\n      if (reconcileTimeoutRef.current !== null) {\n        clearTimeout(reconcileTimeoutRef.current)\n        reconcileTimeoutRef.current = null\n      }\n    }\n  }, [overdrag.dragging, fillPercent])\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on points content via join(\",\") — literal arrays get a new identity every render\n  useEffect(() => {\n    if (process.env.NODE_ENV === 'production') return\n    if (min >= max) {\n      console.warn(\n        `Fader \"${label}\": min (${min}) must be less than max (${max}) — percent math degenerates to NaN.`,\n      )\n    }\n    if (!points) return\n    if (points.length < 2) {\n      console.warn(\n        `Fader \"${label}\": \\`points\\` needs at least 2 entries to be a grammar; got ${points.length}.`,\n      )\n    }\n    const outside = points.filter((p) => p < min || p > max)\n    if (outside.length > 0) {\n      console.warn(`Fader \"${label}\": points outside [${min}, ${max}]: ${outside.join(', ')}.`)\n    }\n  }, [points?.join(','), min, max, label])\n\n  function measureZones() {\n    const control = controlRef.current\n    if (!control) return\n    const controlRect = control.getBoundingClientRect()\n    if (controlRect.width === 0) return\n    const labelRect = labelRef.current?.getBoundingClientRect()\n    const valueRect = valueRef.current?.getBoundingClientRect()\n    zonesRef.current = {\n      labelStart: labelRect ? labelRect.left - controlRect.left : 0,\n      labelEnd: labelRect ? labelRect.right - controlRect.left : 0,\n      valueStart: valueRect ? valueRect.left - controlRect.left : controlRect.width,\n    }\n    trackWidth.set(controlRect.width)\n    updateDodge()\n  }\n\n  useLayoutEffect(() => {\n    measureZones()\n  }, [formatted.length, label, unit, remeasureKey])\n\n  useEffect(() => {\n    const control = controlRef.current\n    if (!control) return\n    const observer = new ResizeObserver(() => measureZones())\n    observer.observe(control)\n    return () => observer.disconnect()\n  }, [])\n\n  function stopSettle() {\n    if (settleRef.current) {\n      settleRef.current.stop()\n      settleRef.current = null\n    }\n    settleTargetRef.current = null\n  }\n\n  function settleTo(pct: number) {\n    stopSettle()\n    if (reducedMotion) {\n      fillPercent.jump(pct)\n      return\n    }\n    settleTargetRef.current = pct\n    settleRef.current = animate(fillPercent, pct, {\n      ...springs.settle,\n      velocity: fillPercent.getVelocity(),\n      onComplete: () => {\n        settleRef.current = null\n        settleTargetRef.current = null\n        if (fillPercent.get() !== percentRef.current) {\n          fillPercent.set(percentRef.current)\n        }\n      },\n    })\n  }\n\n  function handleValueChange(next: number | number[], details: BaseSlider.Root.ChangeEventDetails) {\n    const raw = Array.isArray(next) ? next[0] : next\n    if (raw !== value) {\n      playTickSound()\n    }\n    if (details.reason === 'keyboard') {\n      stopSettle()\n      if (sortedPoints) {\n        const current = nearestIndex(sortedPoints, value)\n        const key = details.event.key\n        let idx: number\n        if (key === 'Home') idx = 0\n        else if (key === 'End') idx = sortedPoints.length - 1\n        else if (key === 'PageUp' || key === 'PageDown') {\n          const jump = Math.max(1, Math.round(sortedPoints.length / LARGE_STEP_FRACTION))\n          idx = Math.min(\n            sortedPoints.length - 1,\n            Math.max(0, current + (key === 'PageUp' ? jump : -jump)),\n          )\n        } else {\n          idx = Math.min(sortedPoints.length - 1, Math.max(0, current + (raw > value ? 1 : -1)))\n        }\n        onValueChange(sortedPoints[idx])\n        return\n      }\n      onValueChange(raw)\n      return\n    }\n    if (details.reason === 'track-press' || details.reason === 'drag') {\n      if (sortedPoints) {\n        const snapped = sortedPoints[nearestIndex(sortedPoints, raw)]\n        if (snapped !== value) {\n          settleTo(toPercent(snapped))\n          onValueChange(snapped)\n        }\n        return\n      }\n      if (isSnappy) {\n        if (raw !== value) {\n          settleTo(toPercent(raw))\n          onValueChange(raw)\n        }\n        return\n      }\n      if (details.reason === 'track-press') {\n        settleTo(toPercent(raw))\n        onValueChange(raw)\n      } else {\n        stopSettle()\n        fillPercent.set(toPercent(raw))\n        onValueChange(raw)\n      }\n      return\n    }\n    stopSettle()\n    onValueChange(raw)\n  }\n\n  return {\n    labelRef,\n    valueRef,\n    rootProps: {\n      value,\n      min,\n      max,\n      step,\n      disabled,\n      format: {\n        minimumFractionDigits: decimals,\n        maximumFractionDigits: decimals,\n      },\n      onValueChange: handleValueChange,\n    },\n    controlProps: {\n      ref: controlRef,\n      onPointerDown: overdrag.onPointerDown,\n    },\n    thumbProps: {\n      getAriaValueText: (formattedValue: string) =>\n        unit ? `${formattedValue}${unit}` : formattedValue,\n      onFocus: (event: React.FocusEvent<HTMLInputElement>) =>\n        setFocusVisible(event.target.matches(':focus-visible')),\n      onBlur: () => setFocusVisible(false),\n    },\n    grabbed: overdrag.dragging,\n    focusVisible,\n    dodge,\n    reducedMotion,\n    markPercents,\n    fillPercent,\n    trackWidth,\n    fillWidth,\n    trackScaleX: overdrag.scaleX,\n    trackOrigin: overdrag.transformOrigin,\n  }\n}\n"
    },
    {
      "path": "components/ui/fader/index.ts",
      "type": "registry:ui",
      "content": "export { Fader } from './fader'\nexport type { FaderProps } from './fader'\nexport type { UseFaderOptions } from './use-fader'\n"
    },
    {
      "path": "lib/sound.ts",
      "type": "registry:lib",
      "content": "'use client'\n\nconst MUTE_KEY = 'sound-muted'\nconst MUTE_EVENT = 'sound-mute-change'\n\nlet muted = false\nif (typeof window !== 'undefined') {\n  try {\n    muted = localStorage.getItem(MUTE_KEY) === '1'\n  } catch {}\n}\n\nexport function isSoundMuted(): boolean {\n  return muted\n}\n\nexport function setSoundMuted(value: boolean) {\n  muted = value\n  if (typeof window !== 'undefined') {\n    try {\n      localStorage.setItem(MUTE_KEY, value ? '1' : '0')\n    } catch {}\n    window.dispatchEvent(new CustomEvent(MUTE_EVENT))\n  }\n}\n\nexport function toggleSoundMuted(): boolean {\n  setSoundMuted(!muted)\n  return muted\n}\n\nexport function subscribeSoundMuted(callback: () => void) {\n  if (typeof window === 'undefined') return () => {}\n  window.addEventListener(MUTE_EVENT, callback)\n  const onStorage = (e: StorageEvent) => {\n    if (e.key === MUTE_KEY) callback()\n  }\n  window.addEventListener('storage', onStorage)\n  return () => {\n    window.removeEventListener(MUTE_EVENT, callback)\n    window.removeEventListener('storage', onStorage)\n  }\n}\n\nlet audioCtx: AudioContext | null = null\n\nfunction initAudio(): AudioContext | null {\n  if (typeof window === 'undefined') return null\n  if (!audioCtx) {\n    const AudioContextClass =\n      window.AudioContext ||\n      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n    if (AudioContextClass) {\n      audioCtx = new AudioContextClass()\n    }\n  }\n  if (audioCtx && audioCtx.state === 'suspended') {\n    audioCtx.resume().catch(() => {})\n  }\n  return audioCtx\n}\n\nif (typeof window !== 'undefined') {\n  const unlock = () => {\n    initAudio()\n    window.removeEventListener('pointerdown', unlock)\n    window.removeEventListener('keydown', unlock)\n    window.removeEventListener('touchstart', unlock)\n  }\n  window.addEventListener('pointerdown', unlock, { passive: true })\n  window.addEventListener('keydown', unlock, { passive: true })\n  window.addEventListener('touchstart', unlock, { passive: true })\n}\n\nexport function getAudioContext(): AudioContext | null {\n  return initAudio()\n}\n\nexport function playHoverSound(volume = 0.12, pitch = 1.2) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1400 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(350 * pitch, now + 0.012)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.012)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.012)\n  } catch {}\n}\n\nexport function playClickSound(volume = 0.2, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'triangle'\n    osc.frequency.setValueAtTime(850 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(160 * pitch, now + 0.02)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.02)\n  } catch {}\n}\n\nexport function playTickSound(volume = 0.1, pitch = 1.5) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1800 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(450 * pitch, now + 0.01)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.01)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.01)\n  } catch {}\n}\n\nexport function playBounceSound(volume = 0.3, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(260 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(650 * pitch, now + 0.04)\n    osc.frequency.exponentialRampToValueAtTime(190 * pitch, now + 0.14)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.14)\n\n    const subOsc = ctx.createOscillator()\n    const subGain = ctx.createGain()\n\n    subOsc.type = 'triangle'\n    subOsc.frequency.setValueAtTime(420 * pitch, now)\n    subOsc.frequency.exponentialRampToValueAtTime(120 * pitch, now + 0.05)\n\n    subGain.gain.setValueAtTime(volume * 0.7, now)\n    subGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05)\n\n    subOsc.connect(subGain)\n    subGain.connect(ctx.destination)\n\n    subOsc.start(now)\n    subOsc.stop(now + 0.05)\n  } catch {}\n}\n"
    }
  ],
  "type": "registry:ui"
}
