{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-multi-voice-piano-roll",
  "type": "registry:ui",
  "title": "Multi-Voice Piano Roll",
  "description": "A reusable short-loop piano-roll editor for independent voices: stable note and voice identity, color-coded overlapping notes, per-note pitch curves with linear interpolation, and keyboard-accessible curve handles. It composes the Piano Roll's tested note grid and leaves continuous pitch delivery to the host's future expression transport.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-piano-roll.json"
  ],
  "files": [
    {
      "path": "components/ui/multi-voice-piano-roll.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/multi-voice-piano-roll\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 * MultiVoicePianoRollEditor — a short-loop roll for independent expressive\n * voices.\n *\n * It deliberately composes PianoRoll for note layout, selection, keyboard\n * editing and transport rather than copying that component. This layer adds\n * the product-level model a pitch-expression editor needs: stable voice/note\n * identity, per-note pitch curves and accessible curve-point handles.\n *\n * Curves are UI/model data only. The consuming module translates them into\n * its chosen event stream; PolySlide, for example, schedules member-channel\n * pitch wheel through the host-owned MIDI face contract. Channel allocation,\n * timing and delivery stay outside this presentational component.\n */\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  SVG_MARKER_DIAMETER_PX,\n  SVG_MARKER_HIT_DIAMETER_PX,\n  SVG_MARKER_OUTLINE_DIAMETER_PX,\n  svgMarkerPath,\n} from \"maruaudio/ui/shared/svg-markers\"\nimport {\n  createPianoRollLayout,\n  resolvePitchWindow,\n} from \"maruaudio/ui/shared/piano-roll-geometry\"\nimport {\n  PianoRoll,\n  type PianoRollNote,\n} from \"@/components/ui/piano-roll\"\nimport {\n  normalizePitchCurve,\n  updatePitchCurvePoint,\n  type MultiVoicePianoRollNote,\n  type PitchCurvePoint,\n} from \"maruaudio/ui/shared/multi-voice-piano-roll-model\"\n\nexport type { MultiVoicePianoRollNote, PitchCurvePoint }\n\nexport interface MultiVoicePianoRollEditorProps {\n  /** Notes are controlled by the consumer; IDs must remain stable while editing. */\n  readonly notes: readonly MultiVoicePianoRollNote[]\n  readonly bars: number\n  readonly beatsPerBar?: number\n  readonly low?: number\n  readonly high?: number\n  readonly pitchPadding?: number\n  readonly minPitchSpan?: number\n  readonly centerPitch?: boolean\n  readonly playhead?: number | null\n  readonly height?: number\n  readonly width?: number | string\n  readonly showGutter?: boolean\n  readonly gutterStyle?: \"names\" | \"numbers\" | \"keys\"\n  readonly gutterWidth?: number\n  readonly subdivisions?: number\n  readonly scheme?: \"dark\" | \"light\" | \"inherit\"\n  readonly showPosition?: boolean\n  readonly color?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n  readonly \"aria-label\"?: string\n  readonly selectedIds?: readonly string[]\n  readonly onSelectionChange?: (selectedIds: string[]) => void\n  readonly onNoteActivate?: (note: MultiVoicePianoRollNote) => void\n  readonly onNoteChange?: (\n    id: string,\n    next: { midi: number; startBeats: number; durationBeats: number }\n  ) => void\n  /** Called when a curve handle moves; the consumer owns persistence. */\n  readonly onPitchCurveChange?: (id: string, curve: PitchCurvePoint[]) => void\n  readonly showPitchCurves?: boolean\n  readonly pitchCurveColor?: string\n}\n\nconst VOICE_COLORS = [\n  \"#8de0d0\",\n  \"#f6ba76\",\n  \"#b8a1ff\",\n  \"#f28da8\",\n  \"#83bdf6\",\n] as const\n\ninterface CurveDrag {\n  readonly noteId: string\n  readonly pointIndex: number\n}\n\nfunction curveY(high: number, pitch: number, rowHeight: number): number {\n  return (high - pitch) * rowHeight + rowHeight / 2\n}\n\nfunction curvePath(\n  note: MultiVoicePianoRollNote,\n  points: readonly PitchCurvePoint[],\n  layout: ReturnType<typeof createPianoRollLayout>\n): string {\n  return points\n    .map((point, index) => {\n      const beat = note.startBeats + point.progress * note.durationBeats\n      const x = layout.xForBeat(beat)\n      const y = curveY(\n        layout.high,\n        note.midi + point.semitones,\n        layout.rowHeight\n      )\n      return `${index === 0 ? \"M\" : \"L\"} ${x.toFixed(3)} ${y.toFixed(3)}`\n    })\n    .join(\" \")\n}\n\nexport function MultiVoicePianoRollEditor({\n  notes,\n  bars,\n  beatsPerBar = 4,\n  low,\n  high,\n  pitchPadding = 2,\n  minPitchSpan = 24,\n  centerPitch = true,\n  playhead = null,\n  height = 280,\n  width = \"100%\",\n  showGutter = true,\n  gutterStyle = \"names\",\n  gutterWidth,\n  subdivisions = 4,\n  scheme = \"inherit\",\n  showPosition = true,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n  selectedIds,\n  onSelectionChange,\n  onNoteActivate,\n  onNoteChange,\n  onPitchCurveChange,\n  showPitchCurves = true,\n  pitchCurveColor,\n}: MultiVoicePianoRollEditorProps) {\n  const accent = color ?? token.accent\n  const curveAccent = pitchCurveColor ?? accent\n  const pitches = useMemo(() => notes.map((note) => note.midi), [notes])\n  const pitchWindow = useMemo(\n    () =>\n      resolvePitchWindow(pitches, {\n        padding: pitchPadding,\n        minSpan: minPitchSpan,\n        center: centerPitch,\n        low,\n        high,\n      }),\n    [pitches, pitchPadding, minPitchSpan, centerPitch, low, high]\n  )\n  const gutter = !showGutter\n    ? 0\n    : gutterWidth !== undefined\n      ? Math.max(0, gutterWidth)\n      : gutterStyle === \"keys\"\n        ? 40\n        : undefined\n  const rowHeight = Math.max(\n    6,\n    Math.min(\n      16,\n      (height - 22) / Math.max(1, pitchWindow.high - pitchWindow.low + 1)\n    )\n  )\n  const containerRef = useRef<HTMLDivElement | null>(null)\n  const [measuredWidth, setMeasuredWidth] = useState<number | null>(null)\n  const useIsomorphicLayoutEffect =\n    typeof window === \"undefined\" ? useEffect : useLayoutEffect\n  useIsomorphicLayoutEffect(() => {\n    const container = containerRef.current\n    const surface = container?.querySelector<SVGSVGElement>('svg[role=\"img\"]')\n    if (!container || !surface || typeof ResizeObserver === \"undefined\") return\n    // PianoRoll measures this same SVG border box and rounds to layout pixels.\n    // Both layers must use that width: the default 320-unit box stretches the\n    // fixed gutter and moves pitch handles relative to the underlying notes.\n    const measure = () => {\n      const next = Math.round(surface.getBoundingClientRect().width)\n      if (next > 0)\n        setMeasuredWidth((previous) => (previous === next ? previous : next))\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(container)\n    observer.observe(surface)\n    return () => observer.disconnect()\n  }, [showPitchCurves])\n\n  const layout = useMemo(\n    () =>\n      createPianoRollLayout({\n        low: pitchWindow.low,\n        high: pitchWindow.high,\n        bars,\n        beatsPerBar,\n        rowHeight,\n        ...(measuredWidth !== null ? { width: measuredWidth } : {}),\n        ...(gutter !== undefined ? { gutter } : {}),\n      }),\n    [\n      pitchWindow.low,\n      pitchWindow.high,\n      bars,\n      beatsPerBar,\n      rowHeight,\n      gutter,\n      measuredWidth,\n    ]\n  )\n\n  const rollNotes = useMemo<PianoRollNote[]>(\n    () =>\n      notes.map((note) => ({\n        id: note.id,\n        midi: note.midi,\n        startBeats: note.startBeats,\n        durationBeats: note.durationBeats,\n        velocity: note.velocity,\n        color: note.color ?? VOICE_COLORS[voiceIndex(notes, note.voiceId)],\n        label: note.label ?? note.voiceId,\n      })),\n    [notes]\n  )\n  const overlayRef = useRef<SVGSVGElement | null>(null)\n  const [curveDrag, setCurveDrag] = useState<CurveDrag | null>(null)\n\n  useEffect(() => {\n    if (!curveDrag || !onPitchCurveChange) return\n    const move = (event: globalThis.PointerEvent) => {\n      const note = notes.find((candidate) => candidate.id === curveDrag.noteId)\n      const rect = overlayRef.current?.getBoundingClientRect()\n      if (!note || !rect || rect.width === 0 || rect.height === 0) return\n      const x = ((event.clientX - rect.left) / rect.width) * layout.width\n      const y = ((event.clientY - rect.top) / rect.height) * layout.height\n      const beat = layout.beatForX(x)\n      const progress = Math.min(\n        1,\n        Math.max(\n          0,\n          (beat - note.startBeats) / Math.max(0.000001, note.durationBeats)\n        )\n      )\n      const pitch =\n        pitchWindow.high - (y - layout.rowHeight / 2) / layout.rowHeight\n      onPitchCurveChange(\n        note.id,\n        updatePitchCurvePoint(note.pitchCurve, curveDrag.pointIndex, {\n          progress,\n          semitones: pitch - note.midi,\n        })\n      )\n    }\n    const up = () => setCurveDrag(null)\n    window.addEventListener(\"pointermove\", move)\n    window.addEventListener(\"pointerup\", up)\n    return () => {\n      window.removeEventListener(\"pointermove\", move)\n      window.removeEventListener(\"pointerup\", up)\n    }\n  }, [curveDrag, layout, notes, onPitchCurveChange, pitchWindow.high])\n\n  const label =\n    ariaLabel ??\n    `Multi-voice piano roll: ${notes.length} note${notes.length === 1 ? \"\" : \"s\"} across ${bars} bar${bars === 1 ? \"\" : \"s\"}`\n\n  return (\n    <div\n      ref={containerRef}\n      className={className}\n      style={{ position: \"relative\", width, ...style }}\n      role=\"group\"\n      aria-label={label}\n      data-ui=\"multi-voice-piano-roll-editor\"\n    >\n      <PianoRoll\n        notes={rollNotes}\n        bars={bars}\n        beatsPerBar={beatsPerBar}\n        low={pitchWindow.low}\n        high={pitchWindow.high}\n        pitchPadding={0}\n        minPitchSpan={1}\n        centerPitch={false}\n        playhead={playhead}\n        height={height}\n        width=\"100%\"\n        showGutter={showGutter}\n        gutterStyle={gutterStyle}\n        gutterWidth={gutterWidth}\n        subdivisions={subdivisions}\n        scheme={scheme}\n        color={accent}\n        showPosition={showPosition}\n        selectedIds={selectedIds}\n        onSelectionChange={onSelectionChange}\n        onNoteActivate={(note) => {\n          const original = notes.find((candidate) => candidate.id === note.id)\n          if (original) onNoteActivate?.(original)\n        }}\n        experimentalEdit={Boolean(onNoteChange)}\n        onNoteChange={onNoteChange}\n      />\n      {showPitchCurves ? (\n        <svg\n          ref={overlayRef}\n          aria-hidden={!onPitchCurveChange}\n          viewBox={`0 0 ${layout.width} ${layout.height}`}\n          preserveAspectRatio=\"none\"\n          style={{\n            position: \"absolute\",\n            top: 0,\n            left: 0,\n            width: \"100%\",\n            height: layout.height,\n            // Match PianoRoll's one-pixel SVG border, including its content\n            // origin and scaling in both content-box and border-box hosts.\n            border: \"1px solid transparent\",\n            pointerEvents: \"none\",\n          }}\n        >\n          {notes.map((note) => {\n            const normalized = normalizePitchCurve(note.pitchCurve)\n            const points = normalized.length\n              ? normalized\n              : [\n                  { progress: 0, semitones: 0 },\n                  { progress: 1, semitones: 0 },\n                ]\n            const colorForVoice =\n              note.color ?? VOICE_COLORS[voiceIndex(notes, note.voiceId)]\n            const selected = selectedIds?.includes(note.id) ?? false\n            return (\n              <g key={`curve-${note.id}`}>\n                <path\n                  d={curvePath(note, points, layout)}\n                  fill=\"none\"\n                  stroke={colorForVoice ?? curveAccent}\n                  strokeWidth={selected ? 2.5 : 1.5}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  opacity={selected ? 1 : 0.65}\n                />\n                {onPitchCurveChange &&\n                  selected &&\n                  normalized.map((point, index) => {\n                    const x = layout.xForBeat(\n                      note.startBeats + point.progress * note.durationBeats\n                    )\n                    const y = curveY(\n                      layout.high,\n                      note.midi + point.semitones,\n                      layout.rowHeight\n                    )\n                    return (\n                      <g key={`${note.id}-curve-point-${index}`}>\n                        <path\n                          d={svgMarkerPath(x, y)}\n                          fill=\"none\"\n                          stroke=\"transparent\"\n                          strokeWidth={SVG_MARKER_HIT_DIAMETER_PX}\n                          strokeLinecap=\"round\"\n                          vectorEffect=\"non-scaling-stroke\"\n                          pointerEvents=\"stroke\"\n                          tabIndex={0}\n                          role=\"slider\"\n                          aria-label={`${note.voiceId} pitch point ${index + 1}`}\n                          aria-valuemin={-48}\n                          aria-valuemax={48}\n                          aria-valuenow={point.semitones}\n                          style={{ cursor: \"ns-resize\", outline: \"none\" }}\n                          onPointerDown={(\n                            event: PointerEvent<SVGPathElement>\n                          ) => {\n                            event.stopPropagation()\n                            event.currentTarget.setPointerCapture?.(\n                              event.pointerId\n                            )\n                            setCurveDrag({ noteId: note.id, pointIndex: index })\n                          }}\n                          onKeyDown={(event: KeyboardEvent<SVGPathElement>) => {\n                            const semitoneStep = event.shiftKey ? 0.1 : 1\n                            const progressStep = event.shiftKey ? 0.01 : 0.05\n                            let next = point\n                            if (event.key === \"ArrowUp\")\n                              next = {\n                                ...point,\n                                semitones: point.semitones + semitoneStep,\n                              }\n                            if (event.key === \"ArrowDown\")\n                              next = {\n                                ...point,\n                                semitones: point.semitones - semitoneStep,\n                              }\n                            if (event.key === \"ArrowLeft\")\n                              next = {\n                                ...point,\n                                progress: point.progress - progressStep,\n                              }\n                            if (event.key === \"ArrowRight\")\n                              next = {\n                                ...point,\n                                progress: point.progress + progressStep,\n                              }\n                            if (next !== point) {\n                              event.preventDefault()\n                              onPitchCurveChange(\n                                note.id,\n                                updatePitchCurvePoint(\n                                  note.pitchCurve,\n                                  index,\n                                  next\n                                )\n                              )\n                            }\n                          }}\n                        />\n                        <path\n                          d={svgMarkerPath(x, y)}\n                          fill=\"none\"\n                          stroke={token.surface}\n                          strokeWidth={SVG_MARKER_OUTLINE_DIAMETER_PX}\n                          strokeLinecap=\"round\"\n                          vectorEffect=\"non-scaling-stroke\"\n                          pointerEvents=\"none\"\n                        />\n                        <path\n                          d={svgMarkerPath(x, y)}\n                          fill=\"none\"\n                          stroke={colorForVoice ?? curveAccent}\n                          strokeWidth={SVG_MARKER_DIAMETER_PX}\n                          strokeLinecap=\"round\"\n                          vectorEffect=\"non-scaling-stroke\"\n                          pointerEvents=\"none\"\n                        />\n                      </g>\n                    )\n                  })}\n              </g>\n            )\n          })}\n        </svg>\n      ) : null}\n    </div>\n  )\n}\n\nfunction voiceIndex(\n  notes: readonly MultiVoicePianoRollNote[],\n  voiceId: string\n): number {\n  const voices: string[] = []\n  for (const note of notes)\n    if (!voices.includes(note.voiceId)) voices.push(note.voiceId)\n  return voices.indexOf(voiceId) % VOICE_COLORS.length\n}\n\nexport default MultiVoicePianoRollEditor\n",
      "type": "registry:component",
      "target": "@ui/multi-voice-piano-roll.tsx"
    }
  ]
}
