{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sound-provider",
  "title": "Ninaero sound-provider",
  "dependencies": [
    "@base-ui/react@^1.9.0",
    "@foleyjs/core@^2.9.0"
  ],
  "registryDependencies": [
    "https://ninaero.pages.dev/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/ninaero/ui/sound-provider.tsx",
      "content": "\"use client\";\n\nimport type { CueName as SoundCue, ThemeName as SoundTheme } from \"@foleyjs/core\";\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport {\n  createSoundPlayer,\n  defaultSoundSettings,\n  type CueOptions as SoundOptions,\n} from \"./sound-runtime\";\n\ntype SoundContextValue = {\n  enabled: boolean;\n  play: (cue: SoundCue, options?: SoundOptions) => void;\n};\n\nconst silentSounds: SoundContextValue = { enabled: false, play: () => {} };\nconst SoundContext = createContext<SoundContextValue>(silentSounds);\n\ntype SoundProviderProps = {\n  children: ReactNode;\n  /** Loads Foley and enables playback when true. Defaults to false. */\n  enabled?: boolean;\n  /** Finite volume from 0 to 1, inclusive. Defaults to 1. */\n  volume?: number;\n  /** Defaults to `glass`, which uses Ninaero's tuned interaction cues. */\n  theme?: SoundTheme;\n  /**\n   * Receives load, configuration, and playback errors after the provider stops its player.\n   * Without this callback, the provider logs errors with `console.error`.\n   */\n  onError?: (error: unknown) => void;\n};\n\n/**\n * Provides opt-in Foley playback for descendants.\n *\n * Requests made before Foley loads are not queued. Browser autoplay restrictions apply.\n * When Foley accepts a cue, that cue replaces this provider's current cue.\n * Disabling or unmounting the provider stops playback.\n *\n * Nested providers use their own settings rather than inheriting the parent's settings.\n */\nfunction SoundProvider({\n  children,\n  enabled = defaultSoundSettings.enabled,\n  volume = defaultSoundSettings.volume,\n  theme = defaultSoundSettings.theme,\n  onError,\n}: SoundProviderProps) {\n  const [player] = useState(() => createSoundPlayer());\n  const reportError = useCallback(\n    (error: unknown) => {\n      player.stop();\n      if (onError) onError(error);\n      else console.error(\"Ninaero could not play sound effects.\", error);\n    },\n    [onError, player],\n  );\n\n  useEffect(() => {\n    // Ignore stale failures so `reportError` cannot stop a newer configuration.\n    let active = true;\n    void player.configure({ enabled, volume, theme }).catch((error: unknown) => {\n      if (active) reportError(error);\n    });\n    return () => {\n      active = false;\n      player.stop();\n    };\n  }, [enabled, volume, theme, reportError, player]);\n\n  const play = useCallback(\n    (cue: SoundCue, options?: SoundOptions) => {\n      if (!enabled) return;\n      try {\n        player.play(cue, options);\n      } catch (error) {\n        reportError(error);\n      }\n    },\n    [enabled, player, reportError],\n  );\n  const value = useMemo(() => ({ enabled, play }), [enabled, play]);\n\n  return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;\n}\n\n/**\n * Returns the nearest provider's `enabled` prop and `play` function.\n *\n * `enabled` does not report whether Foley has loaded or audio can play.\n * Without a provider, `enabled` is false and `play` does nothing.\n *\n * `play` accepts pitch offsets in semitones and a volume multiplier from 0 to 1.\n */\nfunction useSound() {\n  return useContext(SoundContext);\n}\n\nexport { SoundProvider, useSound };\nexport type { SoundCue, SoundOptions, SoundProviderProps, SoundTheme };\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/ninaero/ui/sound-runtime.ts",
      "content": "import type {\n  CueName,\n  PlayHandle,\n  PlayOptions,\n  Spec,\n  ThemeName,\n  ThemeTransform,\n} from \"@foleyjs/core\";\n\ntype SoundCue = CueName;\ntype SoundTheme = ThemeName;\nexport type CueOptions = Pick<PlayOptions, \"pitch\" | \"volume\">;\n\nexport const defaultSoundSettings = {\n  enabled: false,\n  volume: 1,\n  theme: \"glass\" as SoundTheme,\n};\n\ntype CueProfile = {\n  source?: SoundCue;\n  notes?: readonly number[];\n  pace?: number;\n  level?: number;\n  transform: ThemeTransform;\n};\n\n// Page-change/back intervals are shortened and lowered for frequent UI feedback.\nconst cueProfiles: Partial<Record<SoundCue, CueProfile>> = {\n  hover: {\n    source: \"tick\",\n    transform: {\n      pitch: 0.5,\n      attack: 0.5,\n      decay: 1.3,\n      noiseLvl: 0.35,\n      noiseF: 0.25,\n      q: 0.45,\n    },\n  },\n  tick: {\n    source: \"tick\",\n    transform: { pitch: 0.96, attack: 0.25, decay: 0.35, noiseLvl: 0.7 },\n  },\n  tap: {\n    level: 0.625,\n    notes: [739.99],\n    transform: { attack: 0.5, decay: 1.25, noiseLvl: 0.25, noiseF: 0.5 },\n  },\n  on: {\n    notes: [369.99, 554.37],\n    pace: 0.6,\n    level: 0.65,\n    transform: { attack: 0.5, decay: 0.9 },\n  },\n  off: {\n    notes: [554.37, 369.99],\n    pace: 0.6,\n    level: 0.65,\n    transform: { attack: 0.5, decay: 0.9 },\n  },\n  ping: {\n    source: \"ready\",\n    notes: [369.99, 554.37],\n    level: 1,\n    transform: { attack: 0.5, decay: 0.3 },\n  },\n  success: {\n    notes: [739.99, 830.61, 987.77],\n    pace: 0.55,\n    level: 0.7,\n    transform: { attack: 0.5, decay: 0.45 },\n  },\n  complete: {\n    source: \"success\",\n    notes: [659.25, 987.77, 1318.5],\n    pace: 0.55,\n    level: 0.7,\n    transform: { attack: 0.5, decay: 0.65 },\n  },\n  warning: {\n    notes: [1165.5, 1165.5],\n    pace: 0.55,\n    level: 0.65,\n    transform: { attack: 0.5, decay: 0.45 },\n  },\n  error: {\n    notes: [1165.5, 830.61],\n    pace: 0.55,\n    level: 1,\n    transform: { attack: 0.5, decay: 0.45 },\n  },\n  loading: {\n    notes: [369.99, 415.3, 493.88],\n    pace: 0.7,\n    level: 1,\n    transform: { attack: 0.5, decay: 0.7 },\n  },\n};\n\nfunction profileSpec(spec: Spec, profile: CueProfile): Spec {\n  if (\n    profile.notes &&\n    spec.filter((layer) => layer.kind === \"tone\").length !== profile.notes.length\n  ) {\n    throw new RangeError(\"Sound profile must match the source cue's tone count.\");\n  }\n  let tone = 0;\n  return spec.map((layer) => {\n    const next = {\n      ...layer,\n      at: layer.at * (profile.pace ?? 1),\n      peak: layer.peak * (profile.level ?? 1),\n    };\n    const f = next.kind === \"tone\" ? profile.notes?.[tone++] : undefined;\n    return f === undefined ? next : { ...next, f, f2: null };\n  });\n}\n\nexport function getSoundPitch(value: number, min: number, max: number) {\n  if (![value, min, max].every(Number.isFinite) || max <= min) {\n    throw new RangeError(\"Pitch mapping requires finite values and an increasing range.\");\n  }\n  const position = Math.max(0, Math.min(1, (value - min) / (max - min)));\n  return -6 + position * 12;\n}\n\ntype HoverVisit = {\n  played: boolean;\n  rearm?: ReturnType<typeof setTimeout>;\n};\n\nexport function createHoverSoundGate() {\n  const visits = new WeakMap<Element, HoverVisit>();\n\n  return {\n    enter(target: Element, play: () => boolean) {\n      const visit: HoverVisit = visits.get(target) ?? { played: false };\n      visits.set(target, visit);\n      clearTimeout(visit.rearm);\n      visit.rearm = undefined;\n      if (!visit.played) visit.played = play();\n    },\n    leave(target: Element) {\n      const visit = visits.get(target);\n      if (!visit) return;\n      clearTimeout(visit.rearm);\n      visit.rearm = setTimeout(() => visits.delete(target), 200);\n    },\n    dispose(target: Element) {\n      const visit = visits.get(target);\n      if (!visit) return;\n      clearTimeout(visit.rearm);\n      visits.delete(target);\n    },\n  };\n}\n\ntype SoundEngine = Pick<typeof import(\"@foleyjs/core\"), \"play\" | \"set\" | \"getSpec\">;\ntype SoundSettings = typeof defaultSoundSettings;\n\nexport function createSoundPlayer(\n  load: () => Promise<SoundEngine> = () => import(\"@foleyjs/core\"),\n) {\n  let settings = defaultSoundSettings;\n  let engine: SoundEngine | undefined;\n  let loading: Promise<SoundEngine> | undefined;\n  let current: PlayHandle | undefined;\n  let generation = 0;\n  let active = false;\n\n  function stop() {\n    active = false;\n    generation++;\n    current?.stop();\n    current = undefined;\n  }\n\n  return {\n    async configure(options: Partial<SoundSettings> = {}) {\n      const next = { ...defaultSoundSettings, ...options };\n      if (!Number.isFinite(next.volume) || next.volume < 0 || next.volume > 1) {\n        throw new RangeError(\"Sound volume must be between 0 and 1.\");\n      }\n      settings = next;\n      if (!next.enabled) {\n        stop();\n        return;\n      }\n      active = true;\n      const request = ++generation;\n      if (!loading) {\n        loading = load().catch((error: unknown) => {\n          loading = undefined;\n          throw error;\n        });\n      }\n      const loaded = await loading;\n      if (active && generation === request) engine = loaded;\n    },\n    play(cue: SoundCue, options?: CueOptions) {\n      if (!active || !engine) return;\n      if (\n        options?.volume !== undefined &&\n        (!Number.isFinite(options.volume) || options.volume < 0 || options.volume > 1)\n      ) {\n        throw new RangeError(\"Cue volume must be between 0 and 1.\");\n      }\n      if (options?.pitch !== undefined && !Number.isFinite(options.pitch)) {\n        throw new RangeError(\"Cue pitch must be finite.\");\n      }\n      // Foley shares one engine. Apply this provider's settings at the point of playback.\n      engine.set({ theme: settings.theme, volume: settings.volume });\n      const profile = settings.theme === \"glass\" ? cueProfiles[cue] : undefined;\n      if (profile) {\n        const spec = profileSpec(engine.getSpec(profile.source ?? cue), profile);\n        engine.set({\n          theme: {\n            transform: {\n              send: 0,\n              shimmer: false,\n              wave: { triangle: \"sine\", sawtooth: \"sine\" },\n              ...profile.transform,\n            },\n            cues: { [cue]: spec },\n          },\n        });\n      }\n      const next = engine.play(cue, options);\n      if (next) {\n        current?.stop();\n        current = next;\n      }\n    },\n    stop,\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/ninaero/ui/sound-feedback.tsx",
      "content": "\"use client\";\n\nimport { useRender } from \"@base-ui/react/use-render\";\nimport {\n  createContext,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n} from \"react\";\n\nimport { useSound, type SoundCue, type SoundOptions } from \"./sound-provider\";\nimport { createHoverSoundGate, getSoundPitch } from \"./sound-runtime\";\n\nconst SoundScope = createContext(true);\n\nexport function SoundGroup({ sound, children }: { sound: boolean; children: ReactNode }) {\n  const inherited = useContext(SoundScope);\n  return <SoundScope.Provider value={sound && inherited}>{children}</SoundScope.Provider>;\n}\n\nexport function useControlSound(sound = true) {\n  const inherited = useContext(SoundScope);\n  const { enabled, play } = useSound();\n  const active = sound && inherited && enabled;\n  const feedback = useCallback(\n    (cue: SoundCue, options?: SoundOptions) => {\n      if (active) play(cue, options);\n    },\n    [active, play],\n  );\n  return { enabled: active, play: feedback };\n}\n\nconst hoverEvents = new WeakSet<Event>();\nconst hoverSounds = createHoverSoundGate();\nconst optionSelector = '[role=\"option\"], [role^=\"menuitem\"]';\nconst managedOptions =\n  '[data-slot=\"command-item\"], [data-slot=\"select-item\"], [data-slot=\"dropdown-menu-item\"], [data-slot=\"dropdown-menu-checkbox-item\"], [data-slot=\"dropdown-menu-radio-item\"]';\nconst disabledTargets =\n  ':disabled, [aria-disabled=\"true\"], [data-disabled=\"\"], [data-disabled=\"true\"], [data-ninaero-sound=\"off\"]';\nconst textEntryTargets =\n  'input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]):not([type=\"button\"]):not([type=\"submit\"]):not([type=\"reset\"]), textarea, [role=\"textbox\"], [role=\"searchbox\"], [role=\"spinbutton\"]';\nconst hoverTargets =\n  'a[href], button, input, select, summary, label[for], label:has(input), [tabindex]:not([tabindex=\"-1\"]), [role=\"button\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"tab\"], [role=\"combobox\"], [role=\"slider\"], [role=\"separator\"][tabindex], [data-slot=\"slider\"], [data-slot=\"scroll-area-scrollbar\"]';\n\nfunction isTextEntry(target: HTMLElement) {\n  return (\n    target.matches(textEntryTargets) ||\n    target.isContentEditable ||\n    (target instanceof HTMLLabelElement && !!target.control?.matches(textEntryTargets))\n  );\n}\n\nfunction hoverFeedback(\n  event: ReactPointerEvent<HTMLElement>,\n): { cue: SoundCue; options: SoundOptions } | undefined {\n  if (\n    event.defaultPrevented ||\n    (\"baseUIHandlerPrevented\" in event && event.baseUIHandlerPrevented) ||\n    (event.pointerType !== \"mouse\" && event.pointerType !== \"pen\")\n  )\n    return;\n  const target = event.currentTarget;\n  if (\n    target.closest(disabledTargets) ||\n    isTextEntry(target) ||\n    (event.target instanceof HTMLElement && isTextEntry(event.target))\n  )\n    return;\n  if (target.matches(optionSelector)) {\n    if (!target.matches(managedOptions)) return { cue: \"tick\", options: itemSoundOptions(target) };\n    return;\n  }\n  if (target.matches(hoverTargets)) return { cue: \"hover\", options: {} };\n}\n\nexport function HoverSound({\n  children,\n  sound = true,\n}: {\n  children: ReactElement;\n  sound?: boolean;\n}) {\n  const { enabled, play } = useControlSound(sound);\n  const element = useRef<HTMLElement | null>(null);\n  const setElement = useCallback((target: HTMLElement | null) => {\n    if (element.current && element.current !== target) hoverSounds.dispose(element.current);\n    element.current = target;\n  }, []);\n\n  return useRender({\n    render: children,\n    ref: setElement,\n    props: {\n      \"data-ninaero-sound\": enabled ? undefined : \"off\",\n      onPointerEnter(event: ReactPointerEvent<HTMLElement>) {\n        if (!enabled || hoverEvents.has(event.nativeEvent)) return;\n        const feedback = hoverFeedback(event);\n        if (!feedback) return;\n        // Composed controls can decorate the same DOM node more than once.\n        hoverEvents.add(event.nativeEvent);\n        hoverSounds.enter(event.currentTarget, () => {\n          play(feedback.cue, feedback.options);\n          return true;\n        });\n      },\n      onPointerLeave(event: ReactPointerEvent<HTMLElement>) {\n        hoverSounds.leave(event.currentTarget);\n      },\n      onPointerCancel(event: ReactPointerEvent<HTMLElement>) {\n        hoverSounds.leave(event.currentTarget);\n      },\n    },\n  });\n}\n\nexport function itemSoundOptions(element: HTMLElement | null): SoundOptions {\n  const list = element?.closest('[role=\"menu\"], [role=\"listbox\"]');\n  const items = Array.from(\n    list?.querySelectorAll<HTMLElement>(\n      '[role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"menuitemcheckbox\"], [role=\"option\"]',\n    ) ?? [],\n  ).filter((item) => !item.closest(\"[hidden]\"));\n  const index = element ? items.indexOf(element) : -1;\n  return {\n    pitch:\n      index < 0 || items.length < 2\n        ? 0\n        : getSoundPitch(items.length - 1 - index, 0, items.length - 1),\n  };\n}\n\nexport function HighlightSound<State extends { highlighted: boolean; disabled?: boolean }>({\n  state,\n  elementProps,\n  render,\n  sound = true,\n}: {\n  state: State;\n  elementProps: useRender.ElementProps<\"div\">;\n  render?: useRender.RenderProp<State>;\n  sound?: boolean;\n}) {\n  const element = useRef<HTMLDivElement>(null);\n  const previous = useRef(state.highlighted);\n  const { play } = useControlSound(sound);\n\n  useEffect(() => {\n    const entered = state.highlighted && !previous.current;\n    previous.current = state.highlighted;\n    if (entered && !state.disabled) play(\"tick\", itemSoundOptions(element.current));\n  }, [state.highlighted, state.disabled, play]);\n\n  const { ref, ...props } = elementProps;\n  return useRender({\n    defaultTagName: \"div\",\n    ref: [element, ref ?? null],\n    props,\n    render: typeof render === \"function\" ? (renderProps) => render(renderProps, state) : render,\n  });\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}