{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-envelope-editor",
  "type": "registry:ui",
  "title": "Envelope Editor",
  "description": "The envelope display and four knobs, bidirectionally synced — drag a handle and the knobs follow, turn a knob and the shape follows. A worked example of composing the kit rather than a new control. An optional frame wraps it into a compact, self-contained panel — the little-black-box module look.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-envelope-display.json",
    "https://pluginui.com/r/core-knob.json"
  ],
  "files": [
    {
      "path": "components/ui/envelope-editor.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/envelope-editor\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * EnvelopeEditor — the envelope display and its four knobs, kept in sync.\n *\n * This is a composition rather than a new control: the shape on top is\n * `EnvelopeDisplay`, the row underneath is four `Knob`s, and both edit the same\n * value. Drag a handle and the knobs follow; turn a knob and the shape follows.\n * That works because neither child owns the state — this component does, and\n * hands each of them a controlled value.\n *\n * The time knobs use a logarithmic curve so their travel matches the display's\n * sqrt time axis in spirit: the short end, where envelope times actually get\n * chosen, gets most of the throw.\n *\n * By default the pieces float on whatever panel hosts them. Set `frame` to\n * pull them into one self-contained module — surface fill, hairline border,\n * rounded corners, the display recessed like a window — and `variant=\"compact\"`\n * for a dense preset (shorter display, mini knobs, tighter gaps, a fixed\n * four-column knob strip) that fits a channel-strip column.\n */\n\nimport { useCallback, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport type { InteractionEvent } from \"maruaudio/ui/shared/use-first-interaction\"\nimport {\n  DEFAULT_ENVELOPE_RANGES,\n  EnvelopeDisplay,\n  type ADSR,\n  type EnvelopeRanges,\n} from \"@/components/ui/envelope-display\"\nimport { Knob, type KnobVariant } from \"@/components/ui/knob\"\n\nexport type { ADSR } from \"@/components/ui/envelope-display\"\n\nexport interface EnvelopeEditorProps {\n  /** Match an AR engine without exposing unsupported decay/sustain controls. */\n  mode?: \"adsr\" | \"ar\"\n  /** Controlled envelope. Omit for uncontrolled operation. */\n  value?: ADSR\n  /** Starting envelope when uncontrolled, and the reset target. */\n  defaultValue?: ADSR\n  onChange?: (value: ADSR) => void\n  onChangeEnd?: (value: ADSR) => void\n  /** Fires once on the first gesture — for resuming an AudioContext. */\n  onInteraction?: (event: InteractionEvent) => void\n\n  /** Per-segment bounds, shared by the display and the knobs. */\n  ranges?: EnvelopeRanges\n  /** Host parameter paths resolved by the ambient CC mapping provider. */\n  paths?: Partial<Record<keyof ADSR, string>>\n\n  /**\n   * Layout preset. `\"compact\"` shrinks the whole module — a ~104 px display,\n   * mini knobs, tighter gaps, and a fixed four-column knob strip that lines up\n   * under the display instead of wrapping. Explicit `displayHeight` and\n   * `knobVariant` still win over the preset.\n   */\n  variant?: \"default\" | \"compact\"\n  /**\n   * Wrap the editor in a framed panel — `surface` fill, hairline border,\n   * themed corner radius, tight padding — so it reads as one piece of plugin\n   * hardware rather than loose floating parts. The display is recessed into\n   * the panel like a window. Off by default for back-compatibility.\n   */\n  frame?: boolean\n\n  /** Height of the envelope display, in pixels. Defaults to 160 (compact: 104). */\n  displayHeight?: number\n  /** Visual variant passed to the four knobs. Defaults to \"small\" (compact: \"mini\"). */\n  knobVariant?: KnobVariant\n  /** Shape of attack, decay and release, shared with EnvelopeDisplay. */\n  curve?: \"linear\" | \"exponential\"\n  /** Knob diameter override, in pixels. */\n  knobSize?: number\n  /** Marker style for the knobs. */\n  knobIndicator?: \"line\" | \"dot\"\n  /** Hide the knob row, leaving just the draggable shape. */\n  hideKnobs?: boolean\n  /** Render the display's A/D/S/R summary strip. */\n  showSegmentLabels?: boolean\n  /** How the display reads a time out (seconds in); see `EnvelopeDisplay`. */\n  formatTime?: (seconds: number) => string\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nconst DEFAULT_ENVELOPE: ADSR = {\n  attack: 0.01,\n  decay: 0.2,\n  sustain: 0.6,\n  release: 0.4,\n}\n\nexport function EnvelopeEditor({\n  mode = \"adsr\",\n  value,\n  defaultValue = DEFAULT_ENVELOPE,\n  onChange,\n  onChangeEnd,\n  onInteraction,\n  ranges,\n  paths,\n  variant = \"default\",\n  frame = false,\n  displayHeight,\n  knobVariant,\n  curve,\n  knobSize,\n  knobIndicator = \"line\",\n  hideKnobs = false,\n  showSegmentLabels = true,\n  formatTime,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: EnvelopeEditorProps) {\n  const compact = variant === \"compact\"\n  // The preset only fills in what the caller left unspecified, so an explicit\n  // height or knob size always beats the variant.\n  const resolvedDisplayHeight = displayHeight ?? (compact ? 104 : 160)\n  const resolvedKnobVariant = knobVariant ?? (compact ? \"mini\" : \"small\")\n\n  const bounds = {\n    attack: ranges?.attack ?? DEFAULT_ENVELOPE_RANGES.attack,\n    decay: ranges?.decay ?? DEFAULT_ENVELOPE_RANGES.decay,\n    sustain: ranges?.sustain ?? DEFAULT_ENVELOPE_RANGES.sustain,\n    release: ranges?.release ?? DEFAULT_ENVELOPE_RANGES.release,\n  }\n\n  const isControlled = value !== undefined\n  const [internal, setInternal] = useState(defaultValue)\n  const current = isControlled ? value : internal\n\n  const latest = useRef(current)\n  latest.current = current\n\n  const commit = useCallback(\n    (next: ADSR) => {\n      if (!isControlled) setInternal(next)\n      latest.current = next\n      onChange?.(next)\n    },\n    [isControlled, onChange]\n  )\n\n  // One setter per parameter, so a knob only reports its own axis.\n  const setParam = useCallback(\n    (key: keyof ADSR) => (next: number) =>\n      commit({ ...latest.current, [key]: next }),\n    [commit]\n  )\n\n  const endParam = useCallback(\n    () => onChangeEnd?.(latest.current),\n    [onChangeEnd]\n  )\n\n  return (\n    <div\n      className={className}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: compact ? 8 : 14,\n        fontFamily: token.font,\n        // The \"little black box\": one surface, one hairline, tight padding —\n        // the same recipe as a hardware module's faceplate.\n        ...(frame\n          ? {\n              background: token.surface,\n              border: `1px solid ${token.border}`,\n              borderRadius: token.radius,\n              padding: compact ? 10 : 12,\n              boxSizing: \"border-box\" as const,\n            }\n          : null),\n        ...style,\n      }}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Envelope editor\"}\n    >\n      <EnvelopeDisplay\n        mode={mode}\n        curve={curve}\n        value={current}\n        defaultValue={defaultValue}\n        onChange={commit}\n        onChangeEnd={onChangeEnd}\n        onInteraction={onInteraction}\n        ranges={ranges}\n        height={resolvedDisplayHeight}\n        showSegmentLabels={showSegmentLabels}\n        {...(formatTime === undefined ? {} : { formatTime })}\n        inset={frame}\n        disabled={disabled}\n      />\n\n      {!hideKnobs && (\n        <div\n          style={\n            compact\n              ? {\n                  // A fixed four-column grid: the knobs sit evenly under the\n                  // display and can never wrap into a ragged second row.\n                  display: \"grid\",\n                  gridTemplateColumns: `repeat(${mode === \"ar\" ? 2 : 4}, minmax(0, 1fr))`,\n                  justifyItems: \"center\",\n                  alignItems: \"end\",\n                  gap: 6,\n                }\n              : {\n                  display: \"flex\",\n                  justifyContent: \"space-around\",\n                  alignItems: \"flex-end\",\n                  gap: 10,\n                  flexWrap: \"wrap\",\n                }\n          }\n        >\n          <Knob\n            label=\"Attack\"\n            path={paths?.attack}\n            value={current.attack}\n            min={bounds.attack.min}\n            max={bounds.attack.max}\n            defaultValue={defaultValue.attack}\n            // Log curve: the first 50 ms deserve as much travel as the last 3 s.\n            curve=\"log\"\n            valueDisplay=\"ms\"\n            variant={resolvedKnobVariant}\n            size={knobSize}\n            indicator={knobIndicator}\n            disabled={disabled}\n            onChange={setParam(\"attack\")}\n            onChangeEnd={endParam}\n            onInteraction={onInteraction}\n          />\n          {mode === \"adsr\" ? (\n            <>\n              <Knob\n                label=\"Decay\"\n                path={paths?.decay}\n                value={current.decay}\n                min={bounds.decay.min}\n                max={bounds.decay.max}\n                defaultValue={defaultValue.decay}\n                curve=\"log\"\n                valueDisplay=\"ms\"\n                variant={resolvedKnobVariant}\n                size={knobSize}\n                indicator={knobIndicator}\n                disabled={disabled}\n                onChange={setParam(\"decay\")}\n                onChangeEnd={endParam}\n                onInteraction={onInteraction}\n              />\n              <Knob\n                label=\"Sustain\"\n                path={paths?.sustain}\n                value={current.sustain}\n                min={bounds.sustain.min}\n                max={bounds.sustain.max}\n                defaultValue={defaultValue.sustain}\n                valueDisplay=\"percent\"\n                variant={resolvedKnobVariant}\n                size={knobSize}\n                indicator={knobIndicator}\n                disabled={disabled}\n                onChange={setParam(\"sustain\")}\n                onChangeEnd={endParam}\n                onInteraction={onInteraction}\n              />\n            </>\n          ) : null}\n          <Knob\n            label=\"Release\"\n            path={paths?.release}\n            value={current.release}\n            min={bounds.release.min}\n            max={bounds.release.max}\n            defaultValue={defaultValue.release}\n            curve=\"log\"\n            valueDisplay=\"ms\"\n            variant={resolvedKnobVariant}\n            size={knobSize}\n            indicator={knobIndicator}\n            disabled={disabled}\n            onChange={setParam(\"release\")}\n            onChangeEnd={endParam}\n            onInteraction={onInteraction}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/envelope-editor.tsx"
    }
  ]
}
