# Piano roll

> A note editor on a grid: draw, move, resize and select notes, with a playable keyboard and a velocity lane.

```tsx
"use client";

import {
  findNotes,
  type Note,
  type NoteId,
  type NoteLevel,
  type NoteQuery,
  selectionSpan,
  toggleMuted,
} from "@audio-ui/core/piano-roll";
import { pitchClassName, type Scale } from "@audio-ui/core/pitch";
import type { Tempo } from "@audio-ui/core/time";
import {
  type ChangeEvent,
  type DragEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type RefObject,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
  DropdownMenuCheckboxItem,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
} from "@/components/ui/dropdown-menu";
import { Fader } from "@/components/audio-ui/fader";
import {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuItems,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
} from "@/components/audio-ui/note-edit-menu";
import { NoteFind } from "@/components/audio-ui/note-find";
import {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollSelectionSpan,
  PianoRollStretchHandle,
  PianoRollStrip,
  PianoRollVelocity,
  PianoRollVelocityScale,
  PianoRollViewport,
} from "@/components/audio-ui/piano-roll";
import {
  ScaleMenu,
  ScaleMenuContent,
  ScaleMenuName,
  ScaleMenuRoot,
  ScaleMenuToggle,
  ScaleMenuTrigger,
} from "@/components/audio-ui/scale-menu";
import {
  Timeline,
  TimelineLane,
  TimelineMark,
  TimelineNavigator,
  TimelinePlayhead,
  TimelineRange,
  TimelineRangeHandle,
  TimelineRuler,
} from "@/components/audio-ui/timeline";
import { Transport } from "@/components/audio-ui/transport";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  DEFAULT_NOTE_ENVELOPE,
  type Meter,
  meterOf,
  usePianoRollPlayback,
} from "@/hooks/use-piano-roll-playback";
import { useSynthVoices } from "@/hooks/use-synth-voices";
import type { TransportRegion } from "@/hooks/use-transport-clock";
import { useUndoHistory } from "@/hooks/use-undo-history";
import {
  downloadMidiFile,
  MIDI_FILE_ACCEPT,
  readMidiFile,
} from "@/lib/midi-file";
import {
  applyNoteEdit,
  canApplyNoteEdit,
  type NoteEdit,
  noteEditForKey,
  PITCH_EDITS,
  PLAY_EDITS,
  TIME_EDITS,
  VELOCITY_EDITS,
} from "@/lib/note-edits";
import { webAudio } from "@/lib/web-audio";

/** C2 to C6: more than fits, so the roll scrolls and opens on middle C. */
const MIN_PITCH = 36;
const MAX_PITCH = 84;
const DEFAULT_VOLUME = 0.7;
/** The clip is four bars long, in whatever signature it is in. */
const BARS = 4;
/** The widest a step gets, in px: zoomed in all the way, a beat still shows its steps. */
const MAX_ZOOM = 64;
/** The room a ruler label needs, in px: bars, beats and sixteenths each get labels once they are this far apart. */
const LABEL_SPACING = 40;
/** The shortest a note gets from an edit: one step, the grid's. */
const MIN_LENGTH = 1;

const describeStep = (step: number) => `Step ${Math.floor(step) + 1}`;
/** "C4, step 5, 2 steps": where a note is, in the grid's own terms. */
const describeNote = (note: Note) =>
  `${pitchClassName(note.pitch)}${Math.floor(note.pitch / 12) - 1}, step ${note.start + 1}, ${note.length} ${note.length === 1 ? "step" : "steps"}`;

type LoopRegion = TransportRegion;

interface RulerMark {
  label: string;
  level: "bar" | "beat" | "step";
  step: number;
}

const DEPTH = { bar: 1, beat: 2, step: 3 } as const;

/** Where a step falls in its bar and beat, and which of the three it starts. */
function placeOf(step: number, meter: Meter) {
  const bar = Math.floor(step / meter.stepsPerBar);
  const inBar = step % meter.stepsPerBar;
  const beat = Math.floor(inBar / meter.stepsPerBeat);
  const inBeat = inBar % meter.stepsPerBeat;
  let level: RulerMark["level"] = "step";
  if (inBar === 0) {
    level = "bar";
  } else if (inBeat === 0) {
    level = "beat";
  }
  const label = [bar + 1, beat + 1, inBeat + 1]
    .slice(0, DEPTH[level])
    .join(".");
  return { bar, label, level };
}

/**
 * The ruler's labels at `unit` px a step: every bar, then every beat (1.2),
 * then every sixteenth (1.2.3), each once they are `LABEL_SPACING` apart.
 * Zoomed out further than bars allow, every second or fourth bar.
 */
function rulerMarks(unit: number, meter: Meter): RulerMark[] {
  let barEvery = 1;
  while (
    barEvery * meter.stepsPerBar * unit < LABEL_SPACING &&
    barEvery < BARS
  ) {
    barEvery *= 2;
  }
  const shown = {
    bar: true,
    beat: meter.stepsPerBeat * unit >= LABEL_SPACING,
    step: unit >= LABEL_SPACING,
  };
  const marks: RulerMark[] = [];
  for (let step = 0; step < meter.duration; step += 1) {
    const { bar, label, level } = placeOf(step, meter);
    if (shown[level] && (level !== "bar" || bar % barEvery === 0)) {
      marks.push({ label, level, step });
    }
  }
  return marks;
}

/** The px a step takes when the whole clip fits the view, the furthest out the roll zooms: the viewport's width past the keys, over the clip's steps. */
function useFitZoom(
  viewportRef: RefObject<HTMLDivElement | null>,
  duration: number
) {
  const [fit, setFit] = useState(0);
  useEffect(() => {
    const viewport = viewportRef.current;
    if (!viewport) {
      return;
    }
    const measure = () => {
      const lead = viewport.querySelector<HTMLElement>(
        '[data-slot="piano-roll-strip"] > div'
      );
      setFit((viewport.clientWidth - (lead?.offsetWidth ?? 0)) / duration);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(viewport);
    return () => observer.disconnect();
  }, [duration, viewportRef]);
  return fit;
}

/** The ruler's labels follow the zoom: the grid's width over its steps is the px a step takes. */
function useStepWidth(duration: number) {
  const gridRef = useRef<HTMLDivElement>(null);
  const [unit, setUnit] = useState(0);
  useEffect(() => {
    const grid = gridRef.current;
    if (!grid) {
      return;
    }
    const observer = new ResizeObserver(([entry]) => {
      setUnit((entry?.contentRect.width ?? 0) / duration);
    });
    observer.observe(grid);
    return () => observer.disconnect();
  }, [duration]);
  return { gridRef, unit };
}

/** The edits used most, at the top of the Notes menu where they are reached first. */
const TOP_EDITS = ["split", "join", "quantize", "mute"];
/** The Notes menu's submenus: the edits without a key of their own, by what they change. */
const EDIT_MENUS = [
  { edits: TIME_EDITS, label: "Time" },
  { edits: PITCH_EDITS, label: "Pitch" },
  { edits: VELOCITY_EDITS, label: "Velocity" },
  { edits: PLAY_EDITS, label: "Chance" },
].map((menu) => ({
  ...menu,
  edits: menu.edits.filter((item) => !TOP_EDITS.includes(item.id)),
}));
const KEYED_EDITS = [...TIME_EDITS, ...PLAY_EDITS].filter((item) =>
  TOP_EDITS.includes(item.id)
);

/** Whether a key press belongs to a field or a menu, which keep their keys. */
function typedIntoControl(event: { target: EventTarget | null }) {
  const target = event.target as HTMLElement | null;
  return Boolean(
    target?.closest("input, textarea, select, [role=menu], [role=listbox]")
  );
}

/** Cuts the notes past a clip's new end: what a shorter signature leaves out. */
function notesWithin(notes: Note[], duration: number): Note[] {
  return notes
    .filter((note) => note.start < duration)
    .map((note) => ({
      ...note,
      length: Math.min(note.length, duration - note.start),
    }));
}

export function BlockPianoRoll() {
  const volumeId = useId();
  const volumeLabelId = useId();
  const volumeValueId = useId();
  const helpId = useId();

  const [volume, setVolume] = useState(DEFAULT_VOLUME);
  /** Folded, only the pitches carrying notes get a row (F). */
  const [folded, setFolded] = useState(false);
  const toggleFolded = useCallback(() => setFolded((current) => !current), []);
  const [drawing, setDrawing] = useState(true);
  /** What the lane under the grid shows and edits: each note's velocity, or its chance. */
  const [laneLevel, setLaneLevel] = useState<NoteLevel>("velocity");
  const toggleLaneLevel = useCallback(
    () =>
      setLaneLevel((current) =>
        current === "velocity" ? "chance" : "velocity"
      ),
    []
  );
  const toggleDrawing = useCallback(
    () => setDrawing((current) => !current),
    []
  );
  const [scaleOn, setScaleOn] = useState(false);
  /** Limited to the scale, only its rows and the notes' get a row (G): it marks the scale too. */
  const [scaleOnly, setScaleOnly] = useState(false);
  const toggleScaleOnly = useCallback((on: boolean) => {
    setScaleOnly(on);
    if (on) {
      setScaleOn(true);
    }
  }, []);
  const [scaleChoice, setScaleChoice] = useState<Scale>({
    name: "major",
    root: 0,
  });
  const scale = scaleOn ? scaleChoice : null;
  const [selection, setSelection] = useState<readonly NoteId[]>([]);
  const viewportRef = useRef<HTMLDivElement>(null);
  // Opens on middle C, halfway down the range.
  useEffect(() => {
    const viewport = viewportRef.current;
    if (viewport) {
      viewport.scrollTop = (viewport.scrollHeight - viewport.clientHeight) / 2;
    }
  }, []);
  const [tempo, setTempo] = useState<Tempo>({
    beatsPerBar: 4,
    beatUnit: 4,
    bpm: 120,
  });
  const meter = useMemo(() => meterOf(tempo, BARS), [tempo]);
  const [looping, setLooping] = useState(true);
  /** Px a step, once zoomed from the keys or the navigator; null fits the clip. */
  const [zoomed, setZoomed] = useState<number | null>(null);
  const fit = useFitZoom(viewportRef, meter.duration);
  const [metronome, setMetronome] = useState(false);
  const [loopRegion, setLoopRegion] = useState<LoopRegion | null>(null);
  const loopRange: readonly [number, number] = loopRegion
    ? [loopRegion.start, loopRegion.end]
    : [0, meter.duration];
  /** The loop range moves freely under the pointer; the loop itself is in whole steps. */
  const changeLoopRegion = useCallback(
    ([start, end]: readonly [number, number]) => {
      setLoopRegion({ end: Math.round(end), start: Math.round(start) });
    },
    []
  );
  const masterGain = useMasterBus(volume);

  const voices = useSynthVoices({ destination: masterGain });
  const { releaseNote, triggerNote } = voices;

  const [notes, setNotes] = useState<Note[]>([]);
  const history = useUndoHistory(notes, setNotes);
  const {
    clock,
    commitLoopRegion,
    isPlaying,
    onNoteOff,
    onNoteOn,
    pause,
    play,
    playingPitches,
    position,
    seek,
    stop,
  } = usePianoRollPlayback({
    destination: masterGain,
    looping,
    loopRegion,
    meter,
    metronome,
    notes,
    tempo,
    voices,
  });

  // A MIDI keyboard plays the roll's voices and lights its keys, once the page's MIDI input is connected.
  const midiNoteOn = useCallback(
    (pitch: number, velocity: number) => {
      const ctx = webAudio.getContext();
      if (ctx?.state === "suspended") {
        ctx.resume();
      }
      triggerNote(pitch, DEFAULT_NOTE_ENVELOPE, velocity);
    },
    [triggerNote]
  );
  const midiNoteOff = useCallback(
    (pitch: number) => releaseNote(pitch, DEFAULT_NOTE_ENVELOPE),
    [releaseNote]
  );
  const midi = useMidiInput({ onNoteOff: midiNoteOff, onNoteOn: midiNoteOn });
  const activePitches = useMemo(
    () => [...playingPitches, ...midi.activeNotes],
    [midi.activeNotes, playingPitches]
  );

  /** A double-click on the loop goes back to looping the whole clip. */
  const clearLoopRegion = useCallback(() => {
    setLoopRegion(null);
    commitLoopRegion();
  }, [commitLoopRegion]);

  const playPause = isPlaying ? pause : play;

  /** A new signature lays the clip out in its own bars: notes past its new end go, and the loop goes back to the whole clip. */
  const changeTempo = useCallback(
    (next: Tempo) => {
      const { duration } = meterOf(next, BARS);
      if (duration !== meter.duration) {
        setLoopRegion(null);
        if (notes.some((note) => note.start + note.length > duration)) {
          history.edit(notesWithin(notes, duration));
        }
      }
      setTempo(next);
    },
    [history, meter.duration, notes]
  );

  const nextIdRef = useRef(0);
  const applyEdit = useCallback(
    (item: NoteEdit) => {
      const next = applyNoteEdit(item, notes, selection, {
        at: position.getSnapshot(),
        bounds: {
          duration: meter.duration,
          maxPitch: MAX_PITCH,
          minLength: MIN_LENGTH,
          minPitch: MIN_PITCH,
        },
        loopEnd: loopRegion?.end ?? meter.duration,
        nextId: () => {
          nextIdRef.current += 1;
          return `edit-${nextIdRef.current}`;
        },
        scale,
      });
      history.edit(next);
    },
    [history, loopRegion, meter.duration, notes, position, scale, selection]
  );
  const isEditDisabled = useCallback(
    (item: NoteEdit) =>
      !canApplyNoteEdit(item, {
        hasNotes: notes.length > 0,
        hasScale: scale !== null,
        hasSelection: selection.length > 0,
      }),
    [notes.length, scale, selection.length]
  );

  /** A zoom out to the whole clip, or past it, goes back to fitting it. */
  const changeZoom = useCallback(
    (zoom: number) => setZoomed(zoom > fit + 0.01 ? zoom : null),
    [fit]
  );
  /** Zooms so `start` to `end` fills the view, and scrolls it into place. */
  const zoomOnto = useCallback(
    (start: number, end: number) => {
      const viewport = viewportRef.current;
      if (!viewport || end <= start) {
        return;
      }
      const zoom = Math.min(MAX_ZOOM, (fit * meter.duration) / (end - start));
      changeZoom(zoom);
      requestAnimationFrame(() => {
        viewport.scrollLeft = start * zoom;
      });
    },
    [changeZoom, fit, meter.duration]
  );
  /** Onto the selected notes, or the loop with none. */
  const zoomToSelection = () => {
    const span = selectionSpan(notes, new Set(selection));
    const [start, end] = span ? [span.start, span.end] : loopRange;
    zoomOnto(start, end);
  };
  const zoomOut = () => setZoomed(null);
  /** Selects the notes a Find query matches, in the scale marked when it asks for one. */
  const findAndSelect = useCallback(
    (query: NoteQuery) => setSelection(findNotes(notes, query)),
    [notes]
  );
  /** Selects the notes that are not selected, and deselects those that are. */
  const invertSelection = useCallback(() => {
    const selected = new Set(selection);
    setSelection(
      notes.filter((note) => !selected.has(note.id)).map((note) => note.id)
    );
  }, [notes, selection]);
  /** Cmd/Ctrl with Z undoes (with Shift, redoes), with Shift + A inverts the selection, with E, J and U split, join and quantize. */
  const commandKey = (key: string, shiftKey: boolean) => {
    if (key === "z") {
      if (shiftKey) {
        history.redo();
      } else {
        history.undo();
      }
      return true;
    }
    if (key === "a" && shiftKey) {
      invertSelection();
      return true;
    }
    const item = noteEditForKey(key, shiftKey);
    if (item && !isEditDisabled(item)) {
      applyEdit(item);
      return true;
    }
    return false;
  };
  /** B draws, F folds onto the notes, G limits the rows to the scale and K marks it, 0 deactivates the selection, Z zooms onto it and X out to the whole clip. */
  const plainKeys: Record<string, (() => void) | undefined> = {
    0: () => {
      if (selection.length > 0) {
        history.edit(toggleMuted(notes, new Set(selection)));
      }
    },
    b: toggleDrawing,
    f: toggleFolded,
    g: () => toggleScaleOnly(!scaleOnly),
    k: () => setScaleOn((current) => !current),
    x: zoomOut,
    z: zoomToSelection,
  };
  const plainKey = (key: string) => {
    const action = plainKeys[key];
    action?.();
    return action !== undefined;
  };
  /** The editor's keys, from anywhere inside it. */
  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.defaultPrevented || event.altKey || typedIntoControl(event)) {
      return;
    }
    const key = event.key.toLowerCase();
    const handled =
      event.metaKey || event.ctrlKey
        ? commandKey(key, event.shiftKey)
        : plainKey(key);
    if (handled) {
      event.preventDefault();
    }
  };

  useEffect(() => {
    const handleSpace = (e: KeyboardEvent) => {
      // A note takes Space for itself, to select it.
      if (e.code !== "Space" || e.defaultPrevented) {
        return;
      }
      const tag = (e.target as HTMLElement | null)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "BUTTON") {
        return;
      }
      e.preventDefault();
      playPause();
    };
    window.addEventListener("keydown", handleSpace);
    return () => window.removeEventListener("keydown", handleSpace);
  }, [playPause]);

  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const [isDraggingFile, setIsDraggingFile] = useState(false);

  const importMidiFile = useCallback(
    async (file: File) => {
      const imported = await readMidiFile(file, {
        duration: meter.duration,
        maxPitch: MAX_PITCH,
        minPitch: MIN_PITCH,
      });
      stop();
      history.edit(imported.notes);
      setTempo((current) => ({ ...current, bpm: imported.bpm }));
    },
    [history, meter.duration, stop]
  );

  const handleImport = useCallback(() => {
    fileInputRef.current?.click();
  }, []);

  const handleFileInputChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      e.target.value = "";
      if (file) {
        importMidiFile(file);
      }
    },
    [importMidiFile]
  );

  const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
    if (!e.dataTransfer.types.includes("Files")) {
      return;
    }
    e.preventDefault();
    setIsDraggingFile(true);
  }, []);

  const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
    if (e.currentTarget.contains(e.relatedTarget as Node | null)) {
      return;
    }
    setIsDraggingFile(false);
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent<HTMLDivElement>) => {
      const [file] = e.dataTransfer.files;
      if (!file) {
        return;
      }
      e.preventDefault();
      setIsDraggingFile(false);
      importMidiFile(file);
    },
    [importMidiFile]
  );

  const handleExport = useCallback(
    () =>
      downloadMidiFile(notes, { bpm: tempo.bpm, filename: "piano-roll.mid" }),
    [notes, tempo.bpm]
  );
  const clearNotes = useCallback(() => history.edit([]), [history]);

  const { gridRef, unit } = useStepWidth(meter.duration);
  const marks = useMemo(() => rulerMarks(unit, meter), [meter, unit]);

  return (
    <div
      className="rounded-xl @container/panel relative flex w-full min-w-0 flex-col gap-4 border bg-card p-4"
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
      onKeyDown={handleKeyDown}
      role="group"
    >
      <input
        accept={MIDI_FILE_ACCEPT}
        className="sr-only"
        onChange={handleFileInputChange}
        ref={fileInputRef}
        type="file"
      />
      {isDraggingFile ? (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] border-2 border-primary border-dashed bg-background/80 font-medium text-sm"
        >
          Drop a MIDI file to import
        </div>
      ) : null}
      <div className="flex items-center justify-between gap-2 p-1">
        <Transport
          className="min-w-0 flex-1 flex-nowrap"
          looping={looping}
          metronome={metronome}
          onLoopingChange={setLooping}
          onMetronomeChange={setMetronome}
          onPlayPause={playPause}
          onStop={stop}
          onTempoChange={changeTempo}
          playing={isPlaying}
          source={clock}
          tempo={tempo}
        />
        <div className="flex shrink-0 items-center gap-2">
          <ButtonGroup className="hidden @3xl/panel:flex">
            <Button
              aria-label="Undo"
              disabled={!history.canUndo}
              onClick={history.undo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Undo02Icon"
                lucide="Undo2Icon"
                phosphor="ArrowUUpLeftIcon"
                remixicon="RiArrowGoBackLine"
                tabler="IconArrowBackUp"
              />
            </Button>
            <Button
              aria-label="Redo"
              disabled={!history.canRedo}
              onClick={history.redo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Redo02Icon"
                lucide="Redo2Icon"
                phosphor="ArrowUUpRightIcon"
                remixicon="RiArrowGoForwardLine"
                tabler="IconArrowForwardUp"
              />
            </Button>
          </ButtonGroup>
          <ButtonGroup>
            <Button
              aria-label="Draw"
              aria-pressed={drawing}
              onClick={toggleDrawing}
              size="icon"
              title="Draw (B)"
              variant={drawing ? "default" : "outline"}
            >
              <IconPlaceholder
                hugeicons="PencilEdit01Icon"
                lucide="PencilIcon"
                phosphor="PencilSimpleIcon"
                remixicon="RiPencilLine"
                tabler="IconPencil"
              />
            </Button>
            <ScaleMenu
              enabled={scaleOn}
              onEnabledChange={setScaleOn}
              onScaleChange={setScaleChoice}
              scale={scaleChoice}
            >
              <ScaleMenuTrigger />
              <ScaleMenuContent>
                <ScaleMenuToggle>
                  Mark the scale
                  <DropdownMenuShortcut>K</DropdownMenuShortcut>
                </ScaleMenuToggle>
                <DropdownMenuCheckboxItem
                  checked={scaleOnly}
                  onCheckedChange={toggleScaleOnly}
                >
                  Only its rows
                  <DropdownMenuShortcut>G</DropdownMenuShortcut>
                </DropdownMenuCheckboxItem>
                <DropdownMenuSeparator />
                <ScaleMenuRoot />
                <ScaleMenuName />
              </ScaleMenuContent>
            </ScaleMenu>
            <NoteFind onFind={findAndSelect} scale={scale} />
            <NoteEditMenu disabled={isEditDisabled} onEdit={applyEdit}>
              <NoteEditMenuTrigger />
              <NoteEditMenuContent>
                <DropdownMenuGroup>
                  <NoteEditMenuItems edits={KEYED_EDITS} />
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuGroup>
                  {EDIT_MENUS.map((menu) => (
                    <NoteEditMenuSub
                      edits={menu.edits}
                      key={menu.label}
                      label={menu.label}
                    />
                  ))}
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuGroup>
                  <DropdownMenuItem onClick={handleImport}>
                    Import a MIDI file
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    disabled={notes.length === 0}
                    onClick={handleExport}
                  >
                    Export as a MIDI file
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    disabled={notes.length === 0}
                    onClick={clearNotes}
                    variant="destructive"
                  >
                    Clear
                  </DropdownMenuItem>
                </DropdownMenuGroup>
              </NoteEditMenuContent>
            </NoteEditMenu>
          </ButtonGroup>
        </div>
      </div>
      <Timeline
        duration={meter.duration}
        maxZoom={MAX_ZOOM}
        minZoom={fit}
        onPositionChange={seek}
        onZoomChange={changeZoom}
        snapStep={1}
        source={position}
        // Always held here, never below the whole clip: once measured, fitting it is a zoom like any other.
        zoom={fit > 0 ? Math.max(zoomed ?? fit, fit) : undefined}
      >
        <PianoRoll
          activePitches={activePitches}
          className="h-104"
          drawStyle="paint"
          folded={scaleOnly && scale ? "scale" : folded}
          getNoteLabel={describeNote}
          maxPitch={MAX_PITCH}
          minPitch={MIN_PITCH}
          mode={drawing ? "draw" : "select"}
          onNoteOff={onNoteOff}
          onNoteOn={onNoteOn}
          onSelectionChange={setSelection}
          onValueChange={history.change}
          onValueCommitted={history.commit}
          scale={scale}
          selection={selection}
          value={notes}
        >
          <PianoRollStrip className="border-b">
            <TimelineNavigator className="mx-2 my-1.5" />
          </PianoRollStrip>
          <PianoRollViewport ref={viewportRef}>
            <PianoRollStrip
              className="border-b"
              lead={
                <Button
                  aria-pressed={folded}
                  className="h-full w-full rounded-none text-[10px] uppercase tracking-wide"
                  onClick={toggleFolded}
                  size="sm"
                  type="button"
                  variant={folded ? "default" : "ghost"}
                >
                  Fold
                </Button>
              }
              sticky="top"
            >
              <TimelineLane className="h-5.5 cursor-cell bg-muted/40">
                <TimelineRange
                  aria-label="Loop region"
                  className={cn(
                    "rounded-none bg-primary/70 ring-0",
                    !looping && "opacity-40"
                  )}
                  drawable
                  getAriaValueText={describeStep}
                  onDoubleClick={clearLoopRegion}
                  onValueChange={changeLoopRegion}
                  onValueCommitted={commitLoopRegion}
                  value={loopRange}
                >
                  <TimelineRangeHandle
                    aria-label="Loop start"
                    className="rounded-none bg-foreground/20 hover:bg-foreground/40"
                    edge="start"
                  />
                  <TimelineRangeHandle
                    aria-label="Loop end"
                    className="rounded-none bg-foreground/20 hover:bg-foreground/40"
                    edge="end"
                  />
                </TimelineRange>
              </TimelineLane>
              <TimelineRuler
                aria-label="Playhead"
                className="h-4.5 border-b-0 text-[10px]"
                getAriaValueText={describeStep}
              >
                {marks.map((mark) => (
                  <TimelineMark
                    className={cn(
                      "ps-1",
                      mark.level !== "bar" && "text-muted-foreground"
                    )}
                    key={mark.step}
                    value={mark.step}
                  >
                    {mark.label}
                  </TimelineMark>
                ))}
                <PianoRollSelectionSpan className="bg-primary/25">
                  <PianoRollStretchHandle edge="start" />
                  <PianoRollStretchHandle edge="end" />
                </PianoRollSelectionSpan>
                <TimelinePlayhead />
              </TimelineRuler>
            </PianoRollStrip>
            <PianoRollStrip className="flex-1" lead={<PianoRollKeys />}>
              <PianoRollGrid
                accentStep={meter.stepsPerBeat}
                aria-describedby={helpId}
                aria-label="Notes"
                gridStep={1}
                ref={gridRef}
              >
                <TimelinePlayhead />
              </PianoRollGrid>
            </PianoRollStrip>
            <PianoRollStrip
              className="border-t"
              lead={
                <>
                  <Button
                    aria-label={`Lane: ${laneLevel}, show ${laneLevel === "velocity" ? "chance" : "velocity"}`}
                    className="h-5 w-full shrink-0 rounded-none text-[9px] uppercase tracking-wide"
                    onClick={toggleLaneLevel}
                    size="sm"
                    type="button"
                    variant="ghost"
                  >
                    {laneLevel === "velocity" ? "Vel" : "Chance"}
                  </Button>
                  <PianoRollVelocityScale level={laneLevel} />
                </>
              }
              sticky="bottom"
            >
              <PianoRollVelocity
                accentStep={meter.stepsPerBeat}
                gridStep={1}
                level={laneLevel}
              />
            </PianoRollStrip>
          </PianoRollViewport>
        </PianoRoll>
      </Timeline>
      <p className="sr-only" id={helpId}>
        With drawing on (B), drag across the grid to paint notes, one a grid
        step, following the pointer's pitch or with Alt keeping the first; drag
        across the velocity or chance lane to paint each bar to the pointer's
        height. With drawing off, drag to select. Drag a note to move it, with
        Alt to copy it, and drag its edges to change its length; drag an edge of
        the selection on the ruler to stretch it in time. Shift and a click adds
        a note to the selection; Shift and a key selects every note on its row.
        Cmd or Ctrl and a drag on a velocity bar sets how far it may stray. The
        arrow keys move the focused note, Shift with Left or Right changes its
        length, Shift with Up or Down moves it an octave, and Delete removes it.
        Cmd or Ctrl with E chops the selection on the grid, or splits the notes
        at the playhead, with J joins, with U quantizes, with I inserts the
        selection's time, with Shift and D duplicates it, with Shift and Delete
        deletes it, with Shift and A inverts the selection, and with Z undoes. 0
        deactivates the selection, F folds onto the notes, K marks the scale and
        G keeps only its rows, Z zooms onto the selection and X out.
      </p>
      <div className="flex flex-wrap items-center justify-between gap-4">
        <div aria-label="Volume" className="flex-1" role="group">
          <div className="flex w-full min-w-0 flex-col gap-3">
            <div className="flex w-full min-w-0 items-center justify-between gap-4 [&>*:not(output):not(span)]:min-w-0 [&>*:not(output):not(span)]:flex-1">
              <span
                className="text-center text-muted-foreground text-xs"
                id={volumeLabelId}
              >
                Volume
              </span>
              <Fader
                aria-describedby={volumeValueId}
                aria-labelledby={volumeLabelId}
                id={volumeId}
                max={1}
                min={0}
                onValueChange={setVolume}
                orientation="horizontal"
                step={0.01}
                value={volume}
                valueLabel="hidden"
              />
              <output
                aria-live="polite"
                className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums"
                id={volumeValueId}
              >
                {Math.round(volume * 100)}%
              </output>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
```

A piano roll for writing a phrase: a keyboard down the side, notes on a grid, and a velocity bar under each note. It sits inside a [Timeline](/docs/components/base/timeline), so notes use the timeline's units, snap to its grid and follow its zoom.

## Overview

- **Notes:** press on the grid to draw a note or to start a selection rectangle, depending on the mode. Drag a note to move it, drag an edge to resize it, and double-click to delete it. Selected notes move and resize together.
- **Keys:** pressing a key on the side plays its note. The rows light up for the pitches playing.
- **Velocity:** each note has a bar in its own lane, drawn from the bottom.
- **Keyboard:** every edit works without a pointer, including copy, paste, duplicate and quantize.
- **Accessibility:** each note is a toggle button named after its pitch and span. Each velocity bar holds a native `<input type="range">`.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/piano-roll
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/piano-roll.tsx"
"use client";

import {
  PianoRoll as PianoRollPrimitive,
  Timeline as TimelinePrimitive,
} from "@audio-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import {
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";

const pianoRollVariants = cva("", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "min-h-[calc(var(--piano-roll-rows)*0.75rem)]",
      lg: "min-h-[calc(var(--piano-roll-rows)*1rem)]",
      sm: "min-h-[calc(var(--piano-roll-rows)*0.5rem)]",
    },
  },
});

/**
 * The velocities the scale beside the velocity lane labels, in MIDI's 1…127:
 * every 16 when the lane is tall enough, else every 32 or 64, and only the
 * ends when it is short. Labels stay about 13px apart, so they never touch.
 */
function velocityMarks(height: number): number[] {
  const step = [16, 32, 64].find((every) => (height * every) / 127 >= 13);
  if (!step) {
    return [127, 1];
  }
  const marks = [127];
  for (let mark = Math.floor(126 / step) * step; mark >= step; mark -= step) {
    marks.push(mark);
  }
  marks.push(1);
  return marks;
}
/** The chances the scale labels, in percent: every 25 when the lane is tall enough, else every 50, else the ends. */
function chanceMarks(height: number): number[] {
  const step = [25, 50].find((every) => (height * every) / 100 >= 13);
  return step
    ? Array.from({ length: 100 / step + 1 }, (_, index) => 100 - index * step)
    : [100, 0];
}

/** A velocity bar's deviation, from its top: up past it when positive, down into it when negative, as a share of the lane. */
const DEVIATION_BAND = {
  bottom:
    "calc(100% + min(var(--piano-roll-deviation), 0) / max(var(--piano-roll-velocity), 0.01) * 100%)",
  height:
    "calc(max(var(--piano-roll-deviation), -1 * var(--piano-roll-deviation)) / max(var(--piano-roll-velocity), 0.01) * 100%)",
} as const;

/** How tall the velocity lane can be dragged, in px, and how far an arrow key moves it. */
const VELOCITY_HEIGHT = { default: 56, keyStep: 8, max: 240, min: 32 };

/**
 * The tallest the lane can get: half the room it shares with the notes in the
 * viewport, below a strip stuck to the top, so the notes keep the other half.
 */
function tallestLane(element: HTMLElement): number {
  const strip = element.closest<HTMLElement>('[data-slot="piano-roll-strip"]');
  const viewport = element.closest<HTMLElement>(
    '[data-slot="piano-roll-viewport"]'
  );
  if (!(strip && viewport)) {
    return VELOCITY_HEIGHT.max;
  }
  const first = viewport.querySelector<HTMLElement>(
    '[data-slot="piano-roll-strip"]'
  );
  const above =
    first && first !== strip && getComputedStyle(first).position === "sticky"
      ? first.offsetHeight
      : 0;
  return Math.max(
    VELOCITY_HEIGHT.min,
    Math.min(VELOCITY_HEIGHT.max, (viewport.clientHeight - above) / 2)
  );
}

type PianoRollProps = Omit<PianoRollPrimitive.Root.Props, "className"> & {
  className?: string;
};

/**
 * The editor's frame. What it holds lines up on two columns: the keys' on
 * the left, the timeline's beside it. Give it a height; its viewport takes
 * what is left. Sits inside a `Timeline`.
 */
function PianoRoll({ className, ...props }: PianoRollProps) {
  return (
    <PianoRollPrimitive.Root
      className={cn(
        "rounded-xl border bg-background group/piano-roll flex min-h-0 w-full min-w-0 flex-col overflow-hidden [--piano-roll-keys-width:3.5rem] data-disabled:opacity-50",
        className
      )}
      data-slot="piano-roll"
      {...props}
    />
  );
}

/** Scrolls the roll both ways, without scrollbars: put a `TimelineNavigator` above it to scroll and zoom across. */
function PianoRollViewport({
  children,
  className,
  ...props
}: TimelinePrimitive.Viewport.Props) {
  return (
    <TimelinePrimitive.Viewport
      className={cn(
        "relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        className
      )}
      data-slot="piano-roll-viewport"
      {...props}
    >
      {/* As wide as the lanes, so the sticky keys stay put however far it scrolls. */}
      <div className="flex min-h-full w-max min-w-full shrink-0 grow flex-col">
        {children}
      </div>
    </TimelinePrimitive.Viewport>
  );
}

type PianoRollStripProps = ComponentProps<"div"> & {
  /** What sits in the keys' column, stuck to the left edge as the roll scrolls. */
  lead?: ReactNode;
  /** Keeps the strip at the top or the bottom of the viewport. */
  sticky?: "bottom" | "top";
};

/** A row of the editor: `lead` in the keys' column, the lanes in `children` beside it. */
function PianoRollStrip({
  children,
  className,
  lead,
  sticky,
  ...props
}: PianoRollStripProps) {
  return (
    <div
      className={cn(
        "flex min-w-full",
        sticky === "top" && "sticky top-0 z-30",
        sticky === "bottom" && "sticky bottom-0 z-30 mt-auto",
        className
      )}
      data-slot="piano-roll-strip"
      {...props}
    >
      <div className="sticky left-0 z-20 flex w-(--piano-roll-keys-width) shrink-0 flex-col border-e bg-background">
        {lead}
      </div>
      <div className="flex flex-1 flex-col">{children}</div>
    </div>
  );
}

type PianoRollKeysProps = Omit<PianoRollPrimitive.Keys.Props, "className"> &
  VariantProps<typeof pianoRollVariants> & { className?: string };

/** The keyboard down the side, one key per row: press one to hear it, Shift selects its row. */
function PianoRollKeys({ className, size, ...props }: PianoRollKeysProps) {
  return (
    <PianoRollPrimitive.Keys
      aria-label="Keys"
      className={cn(
        "bg-background flex-1 touch-none select-none",
        pianoRollVariants({ size }),
        className
      )}
      data-slot="piano-roll-keys"
      {...props}
    >
      <PianoRollPrimitive.Rows>
        {(pitch) => (
          <PianoRollPrimitive.Key
            className="border-border/60 border-b bg-background text-muted-foreground transition-colors duration-100 ease-out last:border-b-0 hover:bg-muted focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset motion-reduce:transition-none inset-x-0 flex cursor-pointer items-center justify-end pe-1.5 text-[10px] outline-none"
            key={pitch}
            pitch={pitch}
          >
            {/* Only C, where octaves are counted from; folded, rows are not neighbours and each names itself. */}
            <PianoRollPrimitive.KeyLabel
              className={cn(
                pitch % 12 !== 0 && "hidden group-data-folded/piano-roll:inline"
              )}
            />
          </PianoRollPrimitive.Key>
        )}
      </PianoRollPrimitive.Rows>
    </PianoRollPrimitive.Keys>
  );
}

type PianoRollGridProps = Omit<
  PianoRollPrimitive.Grid.Props,
  "className" | "style"
> &
  VariantProps<typeof pianoRollVariants> & {
    /** A firmer line every `accentStep` units, such as each beat. */
    accentStep?: number;
    className?: string;
    /** Distance between grid lines, in the timeline's units. Leave it out for no lines. */
    gridStep?: number;
    style?: CSSProperties;
  };

/** Lines every `gridStep` units, firmer every `accentStep`, sized from the grid's own unit so they follow the zoom. */
function gridLines(
  gridStep: number | undefined,
  accentStep: number | undefined
): CSSProperties | undefined {
  const layers = [
    { color: "var(--timeline-grid-accent-color)", step: accentStep },
    { color: "var(--timeline-grid-color)", step: gridStep },
  ].filter(({ step }) => step !== undefined && step > 0);
  if (layers.length === 0) {
    return;
  }
  return {
    backgroundImage: layers
      .map(
        ({ color }) =>
          `repeating-linear-gradient(to right, ${color} 0 1px, transparent 1px 100%)`
      )
      .join(", "),
    backgroundSize: layers
      .map(({ step }) => `calc(var(--timeline-unit) * ${step}) 100%`)
      .join(", "),
  };
}

/** The notes, on their rows. Anything placed on the timeline, such as a `TimelinePlayhead`, goes in `children`. */
function PianoRollGrid({
  accentStep,
  children,
  className,
  gridStep,
  size,
  style,
  ...props
}: PianoRollGridProps) {
  return (
    <PianoRollPrimitive.Grid
      className={cn(
        "focus-visible:outline-2 focus-visible:outline-ring/60 focus-visible:-outline-offset-2 flex-1 touch-none select-none outline-none data-[mode=draw]:cursor-cell",
        pianoRollVariants({ size }),
        className
      )}
      data-slot="piano-roll-grid"
      style={{ ...gridLines(gridStep, accentStep), ...style }}
      {...props}
    >
      <PianoRollPrimitive.Rows>
        {(pitch) => (
          <PianoRollPrimitive.Row
            className="data-[color=black]:bg-foreground/13 data-folded:border-t data-folded:border-foreground/25 data-octave:border-t data-octave:border-foreground/25 data-active:bg-primary/10 data-in-scale:bg-linear-to-r data-in-scale:from-primary/10 data-in-scale:to-primary/10"
            key={pitch}
            pitch={pitch}
          />
        )}
      </PianoRollPrimitive.Rows>
      <PianoRollSelectionSpan />
      <PianoRollPrimitive.Notes>
        {(note) => (
          <PianoRollPrimitive.Note
            className="rounded-[min(var(--radius-sm),3px)] transition-[border-color,background-color] duration-100 ease-out focus-visible:ring-2 focus-visible:ring-ring/50 data-disabled:pointer-events-none motion-reduce:transition-none cursor-grab touch-none select-none border border-primary bg-[color-mix(in_oklab,var(--primary)_calc(30%_+_var(--piano-roll-velocity)_*_60%),transparent)] opacity-[calc(0.4+var(--piano-roll-chance)*0.6)] outline-none active:cursor-grabbing data-muted:border-muted-foreground data-muted:bg-muted data-[selected]:border-foreground data-[selected]:bg-primary data-[selected]:ring-1 data-[selected]:ring-foreground data-muted:data-[selected]:bg-muted-foreground/40"
            key={note.id}
            note={note}
          >
            <PianoRollPrimitive.NoteHandle
              className="transition-colors hover:bg-foreground/30 motion-reduce:transition-none absolute inset-y-0 w-[min(0.375rem,25%)] cursor-ew-resize data-[edge=end]:right-0 data-[edge=start]:left-0"
              edge="start"
            />
            <PianoRollPrimitive.NoteHandle
              className="transition-colors hover:bg-foreground/30 motion-reduce:transition-none absolute inset-y-0 w-[min(0.375rem,25%)] cursor-ew-resize data-[edge=end]:right-0 data-[edge=start]:left-0"
              edge="end"
            />
          </PianoRollPrimitive.Note>
        )}
      </PianoRollPrimitive.Notes>
      <PianoRollPrimitive.Marquee className="border border-primary bg-primary/15 z-10" />
      {children}
    </PianoRollPrimitive.Grid>
  );
}

type PianoRollVelocityProps = Omit<
  PianoRollPrimitive.VelocityLane.Props,
  "className" | "style"
> & {
  /** A firmer line every `accentStep` units, as on the grid above. */
  accentStep?: number;
  className?: string;
  /** Distance between lines, in the timeline's units: give it the grid's, so each bar lines up with its note. */
  gridStep?: number;
  style?: CSSProperties;
};

/**
 * Each note's velocity as a bar: drag one up or down, or use the arrow keys.
 * Its top edge drags the lane taller or shorter, as do Up and Down on it.
 */
function PianoRollVelocity({
  accentStep,
  children,
  className,
  gridStep,
  level = "velocity",
  style,
  ...props
}: PianoRollVelocityProps) {
  const [height, setHeight] = useState(VELOCITY_HEIGHT.default);
  const [tallest, setTallest] = useState(VELOCITY_HEIGHT.max);
  const dragRef = useRef<{ height: number; y: number } | null>(null);
  const resize = useCallback((element: HTMLElement, next: number) => {
    const max = tallestLane(element);
    setTallest(max);
    setHeight(Math.round(Math.min(max, Math.max(VELOCITY_HEIGHT.min, next))));
  }, []);
  const handlePointerDown = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      event.stopPropagation();
      event.currentTarget.setPointerCapture(event.pointerId);
      dragRef.current = { height, y: event.clientY };
    },
    [height]
  );
  const handlePointerMove = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      const drag = dragRef.current;
      if (drag) {
        resize(event.currentTarget, drag.height + drag.y - event.clientY);
      }
    },
    [resize]
  );
  const handleLostPointerCapture = useCallback(() => {
    dragRef.current = null;
  }, []);
  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const direction = { ArrowDown: -1, ArrowUp: 1 }[event.key];
      if (direction) {
        event.preventDefault();
        resize(
          event.currentTarget,
          height + direction * VELOCITY_HEIGHT.keyStep
        );
      }
    },
    [height, resize]
  );
  return (
    <PianoRollPrimitive.VelocityLane
      aria-label={level === "chance" ? "Chance" : "Velocity"}
      className={cn(
        "bg-background touch-none select-none data-[mode=draw]:cursor-crosshair",
        className
      )}
      data-slot="piano-roll-velocity"
      level={level}
      style={{ ...gridLines(gridStep, accentStep), height, ...style }}
      {...props}
    >
      <div
        aria-label="Velocity lane height"
        aria-orientation="horizontal"
        aria-valuemax={Math.round(tallest)}
        aria-valuemin={VELOCITY_HEIGHT.min}
        aria-valuenow={height}
        className="absolute inset-x-0 -top-1 z-10 h-2 cursor-ns-resize outline-none transition-colors hover:bg-primary/30 focus-visible:bg-ring/50"
        data-slot="piano-roll-velocity-resize"
        onKeyDown={handleKeyDown}
        onLostPointerCapture={handleLostPointerCapture}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        role="separator"
        tabIndex={0}
      />
      <PianoRollSelectionSpan />
      <PianoRollPrimitive.Notes>
        {(note) => (
          <PianoRollPrimitive.Velocity
            className="rounded-t-[min(var(--radius-sm),2px)] bg-primary opacity-60 transition-opacity duration-150 ease-out hover:opacity-100 has-focus-visible:opacity-100 has-focus-visible:ring-2 has-focus-visible:ring-ring/50 data-[selected]:opacity-100 group-data-has-selection/piano-roll:not-data-selected:opacity-25 data-muted:bg-muted-foreground motion-reduce:transition-none w-1.5 cursor-ns-resize after:absolute after:-inset-x-1 after:inset-y-0 group-data-[mode=draw]/piano-roll:cursor-crosshair"
            key={note.id}
            note={note}
          >
            {/* How far the velocity may stray each time it plays, above the bar or into it. */}
            {level === "velocity" ? (
              <span
                aria-hidden="true"
                className="absolute inset-x-0 bg-primary/40"
                style={DEVIATION_BAND}
              />
            ) : null}
          </PianoRollPrimitive.Velocity>
        )}
      </PianoRollPrimitive.Notes>
      {children}
    </PianoRollPrimitive.VelocityLane>
  );
}

/** One edge of the selection's span, for a `PianoRollSelectionSpan`: drag it to stretch the selected notes in time from the other edge. */
function PianoRollStretchHandle({
  className,
  ...props
}: Omit<PianoRollPrimitive.StretchHandle.Props, "className"> & {
  className?: string;
}) {
  return (
    <PianoRollPrimitive.StretchHandle
      className={cn(
        "rounded-[min(var(--radius-sm),2px)] opacity-70 transition-opacity hover:opacity-100 motion-reduce:transition-none z-10 w-2 cursor-ew-resize bg-primary data-[edge=end]:translate-x-1/2 data-[edge=start]:-translate-x-1/2",
        className
      )}
      data-slot="piano-roll-stretch-handle"
      {...props}
    />
  );
}

/** The time the selected notes cover, shaded down the grid, the velocity lane, or a ruler it is put in. */
function PianoRollSelectionSpan({
  className,
  ...props
}: Omit<PianoRollPrimitive.SelectionSpan.Props, "className"> & {
  className?: string;
}) {
  return (
    <PianoRollPrimitive.SelectionSpan
      className={cn("bg-primary/10", className)}
      data-slot="piano-roll-selection"
      {...props}
    />
  );
}

/** The scale beside the velocity lane, for the `lead` of its strip: it labels more values as the lane grows taller, velocities from 1 to 127 or with `level="chance"` chances in percent. */
function PianoRollVelocityScale({
  className,
  level = "velocity",
  ...props
}: ComponentProps<"div"> & {
  level?: PianoRollPrimitive.VelocityLane.Props["level"];
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);
  useEffect(() => {
    const element = ref.current;
    if (!element) {
      return;
    }
    const observer = new ResizeObserver(([entry]) => {
      setHeight(entry?.contentRect.height ?? 0);
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  return (
    <div
      aria-hidden="true"
      className={cn(
        "relative my-1.5 flex-1 text-[10px] text-muted-foreground tabular-nums leading-none",
        className
      )}
      data-slot="piano-roll-velocity-scale"
      ref={ref}
      {...props}
    >
      {(level === "chance" ? chanceMarks(height) : velocityMarks(height)).map(
        (mark) => (
          <span
            className="absolute end-1.5 flex translate-y-1/2 items-center gap-1"
            key={mark}
            style={{
              bottom: `${level === "chance" ? mark : (mark / 127) * 100}%`,
            }}
          >
            {level === "chance" ? `${mark}%` : String(mark)}
            <span className="h-px w-1 bg-muted-foreground" />
          </span>
        )
      )}
    </div>
  );
}

export {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollSelectionSpan,
  PianoRollStretchHandle,
  PianoRollStrip,
  PianoRollVelocity,
  PianoRollVelocityScale,
  PianoRollViewport,
};
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the registry components for the ready-made look, or compose the primitive's parts yourself.

```tsx
import { PianoRoll, Timeline } from "@audio-ui/react";

<Timeline.Root duration={16} snapStep={1}>
  <PianoRoll.Root>
    <PianoRoll.Keys>
      <PianoRoll.Rows>
        {(pitch) => (
          <PianoRoll.Key pitch={pitch}>
            <PianoRoll.KeyLabel />
          </PianoRoll.Key>
        )}
      </PianoRoll.Rows>
    </PianoRoll.Keys>
    <PianoRoll.Grid aria-label="Notes">
      <PianoRoll.Rows>{(pitch) => <PianoRoll.Row pitch={pitch} />}</PianoRoll.Rows>
      <PianoRoll.SelectionSpan>
        <PianoRoll.StretchHandle edge="start" />
        <PianoRoll.StretchHandle edge="end" />
      </PianoRoll.SelectionSpan>
      <PianoRoll.Notes>
        {(note) => (
          <PianoRoll.Note key={note.id} note={note}>
            <PianoRoll.NoteHandle edge="start" />
            <PianoRoll.NoteHandle edge="end" />
          </PianoRoll.Note>
        )}
      </PianoRoll.Notes>
      <PianoRoll.Marquee />
    </PianoRoll.Grid>
    <PianoRoll.VelocityLane aria-label="Velocity">
      <PianoRoll.Notes>
        {(note) => <PianoRoll.Velocity key={note.id} note={note} />}
      </PianoRoll.Notes>
    </PianoRoll.VelocityLane>
  </PianoRoll.Root>
</Timeline.Root>;
```

## Usage

```tsx
import { Timeline, TimelineNavigator } from "@/components/audio-ui/timeline";
import {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollStrip,
  PianoRollVelocity,
  PianoRollVelocityScale,
  PianoRollViewport,
} from "@/components/audio-ui/piano-roll";

<Timeline duration={16} snapStep={1}>
  <PianoRoll
    className="h-96"
    maxPitch={72}
    minPitch={48}
    onNoteOn={(pitch, details) => play(pitch, details.velocity)}
    onValueChange={setNotes}
    value={notes}
  >
    <PianoRollViewport>
      <PianoRollStrip className="flex-1" lead={<PianoRollKeys />}>
        <PianoRollGrid aria-label="Notes" gridStep={1} />
      </PianoRollStrip>
      <PianoRollStrip lead={<PianoRollVelocityScale />} sticky="bottom">
        <PianoRollVelocity gridStep={1} />
      </PianoRollStrip>
    </PianoRollViewport>
  </PianoRoll>
</Timeline>;
```

A note is `{ id, pitch, start, length, velocity?, deviation?, muted?, chance? }`: `pitch` is a MIDI number (60 is middle C), `start` and `length` are in the timeline's units, `velocity` is 0 to 1 (full when left out), `deviation` is how far the velocity may stray each time it plays (-1 to 1, up or down), `muted` deactivates it (it stays on the roll, silent) and `chance` is how likely it is to play each time round, 0 to 1. The roll draws them; playing them is yours, so a sequencer skips a muted note, rolls the dice for its chance and strays its velocity by up to its deviation. `value` and `selection` are controlled; `defaultValue` and `defaultSelection` leave them to the roll. The roll reports every edit through `onValueChange` and, when a gesture or a key press ends, `onValueCommitted`; `eventDetails.edit` tells what happened (`"add"`, `"move"`, `"resize"`, `"remove"`, `"duplicate"`, `"paste"`, `"quantize"`, `"stretch"`, `"velocity"`, `"chance"` or `"deviation"`) and `eventDetails.cancel()` keeps the notes as they were.

`onNoteOn` and `onNoteOff` say when a pitch should sound or stop: a key pressed, a note drawn, picked up or moved to a new pitch.

`scale` marks the rows of a scale (`data-in-scale` on each `PianoRoll.Row`), and `folded="scale"` keeps only those rows and the ones carrying notes. `@audio-ui/core/pitch` holds the scales (`SCALES`, `inScale`, `nearestInScale`), and `@audio-ui/core/piano-roll` the edits a toolbar or a menu applies to the selection, each returning the new notes: `splitNotes`, `chopNotes`, `joinNotes`, `legatoNotes`, `reverseNotes`, `invertNotes`, `stretchNotes`, `fitNotesToScale`, `rampVelocity`, `humanizeNotes`, `toggleMuted`, `setChance`, `setDeviation` and `changeLevel`, and the time commands `deleteTime`, `insertTime` and `duplicateTime`; `selectionSpan` gives the time a selection covers and `findNotes` the notes a query matches, by pitch, velocity, length, chance, scale, state or count.

The toolbar of the preview above is three menus that install on their own: the [Scale menu](/docs/components/base/scale-menu) picks the scale the roll marks, [Note find](/docs/components/base/note-find) selects notes by what they are, and the [Note edit menu](/docs/components/base/note-edit-menu) runs the commands above on the selection.

### Playing the notes

```tsx
"use client";

import type { Note } from "@audio-ui/core/piano-roll";
import type { Tempo } from "@audio-ui/core/time";
import { useState } from "react";
import {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollStrip,
  PianoRollViewport,
} from "@/components/audio-ui/piano-roll";
import { PlayToggle } from "@/components/audio-ui/play-toggle";
import {
  Timeline,
  TimelinePlayhead,
} from "@/components/audio-ui/timeline";
import { useMasterBus } from "@/hooks/use-master-bus";
import {
  meterOf,
  usePianoRollPlayback,
} from "@/hooks/use-piano-roll-playback";
import { useSynthVoices } from "@/hooks/use-synth-voices";

const TEMPO: Tempo = { beatsPerBar: 4, beatUnit: 4, bpm: 110 };
const METER = meterOf(TEMPO, 1);

const PHRASE: Note[] = [
  { id: "1", length: 2, pitch: 60, start: 0, velocity: 0.9 },
  { id: "2", length: 2, pitch: 64, start: 2, velocity: 0.5 },
  { id: "3", length: 2, pitch: 67, start: 4, velocity: 0.8 },
  { id: "4", length: 2, pitch: 72, start: 6, velocity: 1 },
  { id: "5", length: 1, pitch: 71, start: 9, velocity: 0.4 },
  { id: "6", length: 3, pitch: 67, start: 10, velocity: 0.7 },
  { id: "7", length: 3, pitch: 62, start: 13, velocity: 0.6 },
];

/** A bar on loop: edit the notes while it plays, and the next pass plays them. */
export function PianoRollPlaybackDemo() {
  const [notes, setNotes] = useState(PHRASE);
  const output = useMasterBus(0.7);
  const voices = useSynthVoices({ destination: output });
  const playback = usePianoRollPlayback({
    destination: output,
    meter: METER,
    metronome: true,
    notes,
    tempo: TEMPO,
    voices,
  });

  return (
    <div className="flex w-full flex-col gap-3">
      <PlayToggle
        onToggle={playback.isPlaying ? playback.stop : playback.play}
        playing={playback.isPlaying}
      />
      <Timeline
        duration={METER.duration}
        onPositionChange={playback.seek}
        snapStep={1}
        source={playback.position}
      >
        <PianoRoll
          activePitches={playback.playingPitches}
          className="h-64 rounded-md border"
          maxPitch={72}
          minPitch={60}
          onNoteOff={playback.onNoteOff}
          onNoteOn={playback.onNoteOn}
          onValueChange={setNotes}
          value={notes}
        >
          <PianoRollViewport>
            <PianoRollStrip className="flex-1" lead={<PianoRollKeys />}>
              <PianoRollGrid
                accentStep={METER.stepsPerBeat}
                aria-label="Notes"
                gridStep={1}
              >
                <TimelinePlayhead />
              </PianoRollGrid>
            </PianoRollStrip>
          </PianoRollViewport>
        </PianoRoll>
      </Timeline>
    </div>
  );
}
```

`usePianoRollPlayback` plays the notes you hold through a synth: play, pause and stop, a loop or once through, a metronome, and the notes the roll auditions. `npx shadcn@latest add @audio-ui/use-piano-roll-playback`.

```tsx
import { useMasterBus } from "@/hooks/use-master-bus";
import { meterOf, usePianoRollPlayback } from "@/hooks/use-piano-roll-playback";
import { useSynthVoices } from "@/hooks/use-synth-voices";

const tempo = { beatsPerBar: 4, beatUnit: 4, bpm: 120 };
const meter = meterOf(tempo, 4); // four bars, in steps

const output = useMasterBus(0.7);
const voices = useSynthVoices({ destination: output });
const playback = usePianoRollPlayback({ meter, notes, tempo, voices });

<Timeline
  duration={meter.duration}
  onPositionChange={playback.seek}
  snapStep={1}
  source={playback.position}
>
  <PianoRoll
    activePitches={playback.playingPitches}
    onNoteOff={playback.onNoteOff}
    onNoteOn={playback.onNoteOn}
    onValueChange={setNotes}
    value={notes}
  >
    {/* … */}
  </PianoRoll>
</Timeline>;
```

The voices and the bus come from [Synthesis](/docs/engine/synthesis) and [Output](/docs/engine/output), and the clock under the hook from [Clock and time](/docs/engine/clock-and-time). The hook plays the notes; it does not hold them, so an undo, a file import or an agent can change them and the next pass plays the change. Time is in steps, four to a quarter note whatever the signature; `meterOf(tempo, bars)` gives a clip's length in steps and where its bars and beats fall. Notes are scheduled ahead on the audio clock, so they keep time when the page is busy, and each plays as the roll draws it: a deactivated note stays silent, a `chance` plays that often, a `deviation` strays its velocity each time round.

It returns `play` (on from the playhead), `pause` (stops where it is), `stop` (back to the start of the loop), `seek`, `isPlaying`, `playingPitches`, `onNoteOn` and `onNoteOff` for the roll, `position` (in steps) for the timeline's `source`, and `clock` (in seconds) for a [Transport](/docs/components/base/transport)'s `source`. It also takes `looping` (on by default), a `loopRegion` of steps, whose `commitLoopRegion` makes a moved loop heard at once, `metronome` with the `destination` it clicks into, and the `envelope` the notes play with.

### Undo and redo

`useUndoHistory` undoes and redoes any value you hold. `npx shadcn@latest add @audio-ui/use-undo-history`.

```tsx
import { useUndoHistory } from "@/hooks/use-undo-history";

const [notes, setNotes] = useState<Note[]>([]);
const history = useUndoHistory(notes, setNotes);

<PianoRoll
  onValueChange={history.change}
  onValueCommitted={history.commit}
  value={notes}
/>
<Button disabled={!history.canUndo} onClick={history.undo}>Undo</Button>
<Button disabled={!history.canRedo} onClick={history.redo}>Redo</Button>
```

`change` is each step of a gesture and `commit` ends it, so a whole drag undoes in one step. `edit` records a change made in one go, such as a menu command. A new change after an undo drops the redo, and the history keeps the last 100 changes. It works the same for an envelope's points or a sequencer's pattern.

### MIDI files

```tsx
"use client";

import type { Note } from "@audio-ui/core/piano-roll";
import { type ChangeEvent, useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollStrip,
  PianoRollViewport,
} from "@/components/audio-ui/piano-roll";
import { Timeline } from "@/components/audio-ui/timeline";
import {
  downloadMidiFile,
  MIDI_FILE_ACCEPT,
  readMidiFile,
} from "@/lib/midi-file";

const DURATION = 32;
const MIN_PITCH = 48;
const MAX_PITCH = 84;

const PHRASE: Note[] = [
  { id: "1", length: 4, pitch: 60, start: 0, velocity: 0.9 },
  { id: "2", length: 4, pitch: 64, start: 4, velocity: 0.6 },
  { id: "3", length: 4, pitch: 67, start: 8, velocity: 0.8 },
  { id: "4", length: 4, pitch: 72, start: 12, velocity: 1 },
  { id: "5", length: 8, pitch: 65, start: 16, velocity: 0.7 },
  { id: "6", length: 8, pitch: 62, start: 24, velocity: 0.6 },
];

/** Export the clip as a .mid file, or import one onto its grid, with the file's tempo. */
export function PianoRollMidiFileDemo() {
  const [notes, setNotes] = useState(PHRASE);
  const [bpm, setBpm] = useState(120);
  const inputRef = useRef<HTMLInputElement>(null);

  const openFile = useCallback(() => inputRef.current?.click(), []);
  const exportFile = useCallback(
    () => downloadMidiFile(notes, { bpm, filename: "clip.mid" }),
    [bpm, notes]
  );
  const importFile = async (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (file) {
      const imported = await readMidiFile(file, {
        duration: DURATION,
        maxPitch: MAX_PITCH,
        minPitch: MIN_PITCH,
      });
      setNotes(imported.notes);
      setBpm(imported.bpm);
    }
  };

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex items-center justify-between gap-2">
        <p className="font-mono text-muted-foreground text-xs tabular-nums">
          {bpm} BPM
        </p>
        <div className="flex gap-2">
          <input
            accept={MIDI_FILE_ACCEPT}
            className="sr-only"
            onChange={importFile}
            ref={inputRef}
            type="file"
          />
          <Button onClick={openFile} size="sm" variant="outline">
            Import
          </Button>
          <Button disabled={notes.length === 0} onClick={exportFile} size="sm">
            Export
          </Button>
        </div>
      </div>
      <Timeline duration={DURATION} snapStep={1}>
        <PianoRoll
          className="h-64 rounded-md border"
          maxPitch={MAX_PITCH}
          minPitch={MIN_PITCH}
          onValueChange={setNotes}
          value={notes}
        >
          <PianoRollViewport>
            <PianoRollStrip className="flex-1" lead={<PianoRollKeys />}>
              <PianoRollGrid accentStep={4} aria-label="Notes" gridStep={1} />
            </PianoRollStrip>
          </PianoRollViewport>
        </PianoRoll>
      </Timeline>
    </div>
  );
}
```

The `midi-file` library reads a `.mid` file onto the roll's steps and writes one back. `npx shadcn@latest add @audio-ui/midi-file`.

```tsx
import { downloadMidiFile, MIDI_FILE_ACCEPT, readMidiFile } from "@/lib/midi-file";

<input
  accept={MIDI_FILE_ACCEPT}
  onChange={async (event) => {
    const file = event.target.files?.[0];
    if (file) {
      const { bpm, notes } = await readMidiFile(file, {
        duration: 64,
        maxPitch: 84,
        minPitch: 36,
      });
      setNotes(notes);
      setBpm(bpm);
    }
  }}
  type="file"
/>
<Button onClick={() => downloadMidiFile(notes, { bpm, filename: "clip.mid" })}>
  Export
</Button>
```

`readMidiFile` puts each note on the nearest step, at least one step long, drops the notes starting past `duration`, keeps the pitches between `minPitch` and `maxPitch` and returns the file's tempo, kept between `minBpm` and `maxBpm` (20 and 200 by default). It takes any `File` or `Blob`, so a drop works the same as an input. `writeMidiFile` goes the other way, at 480 ticks a quarter note, and leaves deactivated notes out; `downloadMidiFile` writes the file and saves it. A step is a sixteenth note: set `stepsPerQuarter` on both sides for another grid.

### Driving it from an agent

`createPianoRollTools` gives a language model the tools to write on the roll from what a user asks ("a walking bassline in C minor", "up an octave"): `setNotes`, `addNotes`, `removeNotes`, `transpose`, `clear` and `setTempo`, each a description, a zod schema and an `apply` that returns the next state. `npx shadcn@latest add @audio-ui/piano-roll-tools`.

```tsx
import { tool } from "ai";
import { createPianoRollTools } from "@/lib/piano-roll-tools";

const roll = createPianoRollTools({ maxPitch: 72, minPitch: 48, stepCount: 16 });

const tools = Object.fromEntries(
  Object.entries(roll).map(([name, t]) => [
    name,
    tool({
      description: t.description,
      execute: (args) => {
        setState((state) => t.apply(state, args as never));
        return `applied ${name}`;
      },
      inputSchema: t.schema,
    }),
  ])
);
```

The pitch range and the grid are in the schemas, and every note a model writes is clamped into them, so whatever it answers lands on the roll's rows and steps. The tools read and write `{ notes, bpm }` and depend on no agent library: the example wires them to the AI SDK, and any tool-calling API takes the same description and schema.

## Interaction

### Pointer

**Gestures**

| Gesture | Effect |
| --- | --- |
| Press on empty grid | With `mode="draw"`, draws a note whose end follows the pointer. With `mode="select"`, starts a selection rectangle (a double-click draws). `Shift` adds to the selection. |
| Drag a note | Moves it, with the selection when it is part of it. On the grid unless <kbd>Cmd</kbd> / <kbd>Ctrl</kbd> is held. |
| `Alt` + drag a note | Moves a copy. |
| Drag a note's edge | Resizes it: the end changes the length, the start trims it from the front. |
| `Shift` + click a note | Toggles it in the selection. |
| Double-click a note | Deletes it. |
| Drag a velocity bar | Changes the velocity of the selection by the same amount. |
| `Cmd` / `Ctrl` + drag a velocity bar | Sets how far the velocity of the selection may stray each time it plays. |
| Drag across a velocity or chance lane | With `mode="draw"`, paints every bar under the pointer to its height. |
| Drag a stretch handle | Stretches the selection in time from its other edge. |
| Press a key | Plays it; with `Shift`, selects every note on its row. |

### Keyboard

**Keys**

| Key | Effect |
| --- | --- |
| Arrow keys on a note | `Left` / `Right` move it by a grid step, `Up` / `Down` by a semitone. With `Shift`: resize it, or move an octave. |
| `Enter`, `Space` on a note | Toggle it in the selection. |
| `Delete`, `Backspace` | Remove the selected notes. |
| `Enter` on the grid | Adds a note at the playback position. |
| `Escape`, `Cmd` / `Ctrl` + `A` | Clear the selection, select every note. |
| `Cmd` / `Ctrl` + `D` | Repeat the selection right after itself and select the copies. |
| `Cmd` / `Ctrl` + `C`, `X`, `V` | Copy, cut, and paste at the playback position. |
| `Q` | Snap the starts of the selection (every note, with none selected) to the grid. |
| Arrow keys, `Page Up` / `Down`, `Home` / `End` on a velocity bar | Change the velocity by 5%, by 25%, or to silent or full. |

## API reference

### Root

Groups the parts of a piano roll and owns its notes and selection. Must be used within a `Timeline`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `Note[]` |  | The notes. |
| `defaultValue` | `Note[]` |  | Uncontrolled starting notes. |
| `selection` | `NoteId[]` |  | The ids of the selected notes. |
| `defaultSelection` | `NoteId[]` |  | Uncontrolled starting selection. |
| `minPitch` | `number` | `48` | Lowest MIDI pitch, inclusive: 60 is middle C. |
| `maxPitch` | `number` | `72` | Highest MIDI pitch, inclusive. |
| `minLength` | `number` |  | The shortest a note can be, in the timeline's units. Defaults to the timeline's grid step. |
| `mode` | `"draw" \| "select"` | `"select"` | What a press on empty grid does: draw a note, or start a selection rectangle (a double-click draws). |
| `drawStyle` | `"paint" \| "stretch"` | `"stretch"` | What a drag does after a press on the grid in draw mode: stretch the note drawn, or paint a note in each grid cell the pointer crosses, on the pitch under it, or with Alt on the first one. |
| `folded` | `boolean \| "scale"` | `false` | Whether only the pitches carrying notes get a row, and with `"scale"` those in `scale` too. A roll with nothing to fold onto keeps them all. |
| `scale` | `Scale \| null` |  | The scale whose rows are marked `data-in-scale`, such as `{ name: "major", root: 0 }` for C major. |
| `activePitches` | `number[]` |  | Pitches lit from elsewhere, such as a MIDI input or the notes playing, on the keys and rows. |
| `disabled` | `boolean` |  |  |
| `createNoteId` | `() => NoteId` |  | Returns the id of a note drawn, added or duplicated. Defaults to `"note-<n>"`, skipping ids in use. |
| `getNoteLabel` | `(note: Note) => string` |  | Returns a note's accessible name. Defaults to its pitch and where it starts and ends, such as "C4, 4 to 6". |
| `onValueChange` | `(value: Note[], eventDetails) => void` |  | Fires while notes are drawn, moved, resized, duplicated or removed. |
| `onValueCommitted` | `(value: Note[], eventDetails) => void` |  | Fires when a gesture that changed the notes ends, and after each edit from the keyboard. |
| `onSelectionChange` | `(selection: NoteId[], eventDetails) => void` |  | Fires when notes are selected or deselected. |
| `onNoteOn` | `(pitch: number, eventDetails) => void` |  | Fires when a pitch should sound: a key pressed, a note drawn, picked up or moved to a new pitch. `eventDetails.cancel()` keeps it silent. |
| `onNoteOff` | `(pitch: number, eventDetails) => void` |  | Fires when a pitch stops sounding. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-editing` | Present while a gesture edits the notes. |
| `data-folded` | Present when folded. |
| `data-has-selection` | Present while notes are selected. |
| `data-mode` | `"draw"` or `"select"`. |

### Keys

The keyboard down the side: a `Piano` laid out on the roll's rows, low end at the bottom. Pressing a key sounds it through the roll's `onNoteOn`; with Shift, pressing it (or Shift + Enter) selects every note on its row, or deselects them when they all are. Put a `PianoRoll.Key` per row inside, with `PianoRoll.Rows`. Renders a `<div>` element.

### Rows

Renders `children` once per row, from the highest pitch down: the place to put a `PianoRoll.Key` or a `PianoRoll.Row`. Renders no element of its own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(pitch: number) => React.ReactNode` |  |  |

### Key and KeyLabel

The same parts as [Piano](/docs/components/base/piano): a key of the keyboard, and its label.

### Grid

The surface notes are drawn on: a timeline lane, with the roll's rows stacked down it. A press on empty grid draws a note (`mode="draw"`), whose end then follows the pointer, or starts a selection rectangle, Shift adding to what is selected; in that mode a double-click draws. Focused, Enter adds a note at the playback position, Escape clears the selection and Cmd/Ctrl+A selects every note. From the grid or a note, Cmd/Ctrl+D repeats the selection right after itself, Cmd/Ctrl+C, X and V copy, cut and paste at the playback position, and Q snaps the starts of the selection (every note, with none selected) to the grid. Renders a `<div>` element.

### Row

The band a pitch takes across the grid, for shading black-key rows, the rows of the roll's scale, or ruling where an octave starts. It lets presses through to the grid and is hidden from assistive technology. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pitch` | `number` |  | The MIDI pitch of the row. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-active` | Whether the pitch is sounding. |
| `data-color` | `"black"` or `"white"`. |
| `data-folded` | Whether the roll is folded. |
| `data-in-scale` | Whether the pitch is in the roll's `scale`. |
| `data-octave` | Whether the row is a C, where an octave starts. |

### Notes

Renders `children` once per note, in the order they are read: by start, then from the highest pitch down. That order is also the tab order. Renders no element of its own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(note: Note) => React.ReactNode` |  |  |

### Note

One note, placed on its row from its start, as long as its length. A toggle button named after its pitch and span (`getNoteLabel` on `Root`). Its `--piano-roll-velocity` and `--piano-roll-chance` CSS variables are its velocity and how likely it is to play. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `note` | `Note` |  | The note to draw: the roll's copy of it is used once it is edited. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-selected` | Whether the note is in the selection: also `aria-pressed`. |
| `data-muted` | Whether the note is deactivated: it stays on the roll and does not play. |
| `data-disabled` | Present when disabled. |

### NoteHandle

One edge of the note it sits in, dragged to change the note's length: the end makes it longer or shorter, the start trims it from the front while its end stays put. On the grid unless Cmd/Ctrl is held, and for the whole selection when the note is part of it. Shift + Left and Right on the note do the same from the keyboard, so the handle is hidden from assistive technology. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"start" \| "end"` |  | The edge of the note this handle drags. |

### Marquee

The selection rectangle being dragged across the grid; renders nothing the rest of the time. Hidden from assistive technology. Renders a `<div>` element.

### SelectionSpan

The time the selected notes cover, from the earliest start to the latest end, as a band from top to bottom of what it sits in: the grid, the velocity lane or the ruler. Renders nothing with no note selected. It lets presses through, all but its `PianoRoll.StretchHandle`s, and is hidden from assistive technology. Renders a `<div>` element.

### StretchHandle

One edge of the selection's span, for its `SelectionSpan`: a drag stretches the selected notes in time from the other edge, on the grid unless Cmd/Ctrl is held. The Notes edits that double and halve lengths do the same from the keyboard, so it is hidden from assistive technology. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"start" \| "end"` |  | The edge of the span this handle drags; the other stays put. |

### VelocityLane

A strip under the grid where each note's velocity, or with `level="chance"` its chance to play, stands as a bar placed at the note's start: a timeline lane, like the grid. In draw mode it is a brush: a drag across it sets every bar under the pointer, in the grid cell it is over, to the pointer's height. Put a `PianoRoll.Velocity` per note inside, with `PianoRoll.Notes`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `level` | `"velocity" \| "chance"` | `"velocity"` | What the bars stand for: each note's velocity, or its chance to play. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-level` | `"velocity"` or `"chance"`. |
| `data-mode` | `"draw"` or `"select"`: in draw mode the lane is a brush. |

### Velocity

One note's velocity, or its chance in a `level="chance"` lane, as a bar from the bottom of the lane: its height is the value, through the `--piano-roll-level` CSS variable. Dragging it up or down changes the value of the selection by the same amount when the note is part of it, so a phrase keeps its dynamics; Shift + click toggles the note in the selection. On a velocity bar, Cmd/Ctrl + drag sets how far the velocity may stray each time the note plays, its `--piano-roll-deviation`. A native `<input type="range">` inside takes the arrow keys (Page Up/Down for larger steps, Home/End for none and full) and carries the accessible name, which defaults to the note's. The next drawn note gets the velocity set last. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `note` | `Note` |  | The note whose velocity this is. |
| `getAriaValueText` | `(velocity: number) => string` |  | Returns how a velocity, 0…1, is announced. Defaults to a percentage. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-selected` | Whether the note is in the selection. |
| `data-muted` | Whether the note is deactivated. |

### Registry components

`PianoRoll` takes the `Root` props. The rest lay the editor out on two columns, the keys' and the timeline's:

**Components**

| Name | Description |
| --- | --- |
| `PianoRollViewport` | Scrolls the roll both ways, without scrollbars: put a `TimelineNavigator` above it to scroll and zoom across. |
| `PianoRollStrip` | A row of the editor: `lead` in the keys' column, stuck to the left edge, the lanes in `children` beside it. `sticky` keeps it at the `"top"` or `"bottom"` of the viewport. |
| `PianoRollKeys` | The keyboard down the side, one key per row. `size` is `"sm"`, `"default"` or `"lg"`. |
| `PianoRollGrid` | The notes, on their rows. `gridStep` draws a line every step, `accentStep` a firmer one, such as each beat; anything placed on the timeline, such as a `TimelinePlayhead`, goes in `children`. |
| `PianoRollVelocity` | Each note's velocity as a bar. |
| `PianoRollVelocityScale` | The scale beside the velocity lane, for the `lead` of its strip. |
