Note edit menu

A menu of commands over a piano roll's notes, with their keys: split, join, quantize, legato, time commands, fit to scale, chance and velocity.

Audio UI

Installation

pnpm dlx shadcn@latest add @audio-ui/note-edit-menu

Anatomy

<NoteEditMenu>
  <NoteEditMenuTrigger />
  <NoteEditMenuContent>
    <NoteEditMenuItems />
    <NoteEditMenuSub />
  </NoteEditMenuContent>
</NoteEditMenu>

Usage

import {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
} from "@/components/audio-ui/note-edit-menu";
import {
  applyNoteEdit,
  canApplyNoteEdit,
  PITCH_EDITS,
  TIME_EDITS,
  VELOCITY_EDITS,
} from "@/lib/note-edits";

<NoteEditMenu
  disabled={(edit) =>
    !canApplyNoteEdit(edit, {
      hasNotes: notes.length > 0,
      hasScale: scale !== null,
      hasSelection: selection.length > 0,
    })
  }
  onEdit={(edit) =>
    setNotes(
      applyNoteEdit(edit, notes, selection, {
        at: playhead, // in steps: where Split cuts with nothing selected
        bounds: { duration: 64, maxPitch: 84, minLength: 1, minPitch: 36 },
        loopEnd: 64, // what Legato stretches the last notes to
        nextId: () => crypto.randomUUID(), // an id for each note an edit makes
        scale, // what Fit to scale fits to, or null
      })
    )
  }
>
  <NoteEditMenuTrigger />
  <NoteEditMenuContent>
    <NoteEditMenuSub edits={TIME_EDITS} label="Time" />
    <NoteEditMenuSub edits={PITCH_EDITS} label="Pitch" />
    <NoteEditMenuSub edits={VELOCITY_EDITS} label="Velocity" />
  </NoteEditMenuContent>
</NoteEditMenu>;

The item installs two files: the menu, and lib/note-edits.ts, the commands it lists. A command is data, { id, label, shortcut?, edit }, so the menu needs no prop per command: NoteEditMenuItems lists commands as items, NoteEditMenuSub lists them in a submenu, and you pick which go where. Each item shows its key beside it (⌘E), and disabled greys out the commands that can't run: there must be notes, a time command needs a selection and Fit to scale a scale.

onEdit gets the command picked. applyNoteEdit runs it on the selected notes, or on every note with none selected, and returns the new notes. Hand them to useUndoHistory's edit and every command undoes in one step (see Piano roll).

The commands

  • TIME_EDITS: Split (⌘E), Chop in two, Join (⌘J), Quantize (⌘U), Legato, Reverse, Twice as long, Half as long, Duplicate time (⇧⌘D), Delete time (⇧⌘⌫), Insert time (⌘I).
  • PITCH_EDITS: Invert, Fit to scale.
  • PLAY_EDITS: Deactivate (0), Always play, Play 75%, 50% or 25% of the time.
  • VELOCITY_EDITS: Crescendo, Decrescendo, Humanize.

Split cuts the selected notes on every grid step, or, with nothing selected, the notes under the playhead at at. The work is done by the edits in @audio-ui/core/piano-roll (splitNotes, quantizeNotes, fitNotesToScale…); note-edits names them, groups them and gives them keys.

Items of your own

NoteEditMenuContent is a dropdown menu's content, so commands that are not note edits sit beside the parts as plain items. The piano roll block puts its file commands there:

<NoteEditMenuContent>
  <NoteEditMenuItems edits={KEYED_EDITS} />
  <DropdownMenuSeparator />
  <NoteEditMenuSub edits={TIME_EDITS} label="Time" />
  <DropdownMenuSeparator />
  <DropdownMenuItem onClick={importFile}>Import a MIDI file</DropdownMenuItem>
  <DropdownMenuItem onClick={exportFile}>Export as a MIDI file</DropdownMenuItem>
  <DropdownMenuItem onClick={clear} variant="destructive">
    Clear
  </DropdownMenuItem>
</NoteEditMenuContent>

A command of your own is a NoteEdit too:

import { moveNotes } from "@audio-ui/core/piano-roll";
import type { NoteEdit } from "@/lib/note-edits";

const OCTAVE_UP: NoteEdit = {
  edit: (notes, ids, { bounds }) => moveNotes(notes, ids, 0, 12, bounds),
  id: "octave-up",
  label: "Octave up",
};

Without the menu

The same commands run from the keyboard, a toolbar or an agent's tool. noteEditForKey(key, shiftKey) returns the command Cmd/Ctrl with key runs (Shift too for the time commands), or nothing. key is lower case:

const onKeyDown = (event: KeyboardEvent) => {
  if (!(event.metaKey || event.ctrlKey)) return;
  const edit = noteEditForKey(event.key.toLowerCase(), event.shiftKey);
  if (edit && canApplyNoteEdit(edit, state)) {
    event.preventDefault();
    setNotes(applyNoteEdit(edit, notes, selection, context));
  }
};

API reference

NoteEditMenu

Props · 2

Parts

Components · 4

NoteEditMenuTrigger
The button that opens the menu, "Notes" with a caret unless given children. Takes the button's props.
NoteEditMenuContent
The menu. Aligned to the trigger's end unless given align.
NoteEditMenuItems
A menu item per command in edits, with its key beside it.
NoteEditMenuSub
A submenu named label, holding a menu item per command in edits.

note-edits

Props · 4

The context applyNoteEdit takes:

Props · 5

Built by Ouest Labs, the source code is available on GitHub.
RSS