---
title: Arrangement editor
description: "The parts of a DAW's arrangement, each installed on its own: its state, its clips, track reordering, locators and time signatures, automation and MIDI playback, over a Timeline."
component: true
base: base
---

```tsx
"use client";

import type { Breakpoint } from "@audio-ui/core/envelope";
import {
  adjacentDivision,
  barLabelInterval,
  barsBeatsWith,
  barsOf,
  DEFAULT_TEMPO,
  divisionAt,
  GRID_DIVISIONS,
  type GridDivision,
  gridInterval,
  gridLabel,
  type MetronomeRhythm,
  secondsPerBar,
  type Tempo,
  TRIPLET_FACTOR,
} from "@audio-ui/core/time";
import { snapMove } from "@audio-ui/core/timeline";
import {
  adjacentPoint,
  type Timeline as TimelinePrimitive,
} from "@audio-ui/react";
import {
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
  useCallback,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";
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 {
  ContextMenu,
  ContextMenuContent,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
  ArrangedClip,
  ArrangedClipGhost,
} from "@/components/audio-ui/arranged-clip";
import { NumberControl } from "@/components/audio-ui/bpm-control";
import { Meter } from "@/components/audio-ui/meter";
import {
  COMPACT_TRACK_HEIGHT,
  MAX_TRACK_HEIGHT,
  MIN_TRACK_HEIGHT,
  Timeline,
  TimelineGridMenuItems,
  TimelineLane,
  TimelineMark,
  TimelineNavigator,
  TimelinePlayhead,
  TimelineRange,
  TimelineRangeHandle,
  TimelineRuler,
  TimelineTrack,
  TimelineTrackControls,
  TimelineTrackHeader,
  TimelineViewport,
  TRACK_COLORS,
  TRACK_HEIGHT_STEP,
} from "@/components/audio-ui/timeline";
import {
  type Locator,
  type Signature,
  TimelineLocator,
  TimelineSignature,
} from "@/components/audio-ui/timeline-markers";
import {
  AUTOMATED_PARAMETERS,
  TrackAutomation,
  TrackAutomationChooser,
} from "@/components/audio-ui/track-automation";
import { Transport } from "@/components/audio-ui/transport";
import {
  ClipPanel,
  type ClipSettings,
} from "@/components/audio-ui/clip-panel";
import {
  MidiClipPanel,
  type MidiClipRegion,
} from "@/components/audio-ui/midi-clip-panel";
import {
  type ClipAction,
  useArrangement,
} from "@/hooks/use-arrangement";
import { useMarquee } from "@/hooks/use-marquee-selection";
import {
  type LevelsAt,
  useMidiPlayback,
} from "@/hooks/use-midi-playback";
import { useSimulatedClock } from "@/hooks/use-simulated-clock";
import { useSimulatedLevels } from "@/hooks/use-simulated-levels";
import { useTrackReorder } from "@/hooks/use-track-reorder";
import {
  type Arranged,
  type ArrangementTrack,
  type AutomatedParameter,
  type Clipping,
  type Mix,
  mixAt,
  type TimeSelection,
} from "@/lib/arrangement";

const BARS = 32;
/** The arrangement is laid out at the first tempo; a tempo change keeps its musical positions. */
const BAR = secondsPerBar(DEFAULT_TEMPO);
/** The width of a track header, which the lanes start after. */
const LEAD_PX = 160;
const MAX_ZOOM = 240;
/** How close an adaptive grid's lines may be, in px: Cmd/Ctrl + 1 and 2 halve and double it. */
const DENSITIES = [6, 12, 24, 48, 96] as const;
/** Cmd/Ctrl + a digit changes the grid, read from the key's place so it works on any keyboard layout. */
const GRID_KEYS: Record<
  string,
  "finer" | "fixed" | "snap" | "triplet" | "wider"
> = {
  Digit1: "finer",
  Digit2: "wider",
  Digit3: "triplet",
  Digit4: "snap",
  Digit5: "fixed",
};

/** The width of the track headers, which the lanes start after. */
function leadWidth(viewport: HTMLElement) {
  return (
    viewport.querySelector<HTMLElement>('[data-slot="timeline-track-header"]')
      ?.offsetWidth ?? LEAD_PX
  );
}

/** Whether a key went to a field you type in, where it is the field's own. */
function typing(target: EventTarget) {
  return (
    (target instanceof HTMLInputElement && target.type !== "range") ||
    target instanceof HTMLTextAreaElement ||
    (target instanceof HTMLElement && target.isContentEditable)
  );
}
/** The zoom presets, as shares of the fitted view: Fit shows everything. */
const ZOOM_PRESETS = [1, 2, 4, 8];
/** The zoom's steps are loupes, not a minus and a plus. */
const ZOOM_STEPPERS = {
  decrement: "Zoom out",
  icons: {
    decrement: (
      <IconPlaceholder
        hugeicons="ZoomOutAreaIcon"
        lucide="ZoomOutIcon"
        phosphor="MagnifyingGlassMinusIcon"
        remixicon="RiZoomOutLine"
        tabler="IconZoomOut"
      />
    ),
    increment: (
      <IconPlaceholder
        hugeicons="ZoomInAreaIcon"
        lucide="ZoomInIcon"
        phosphor="MagnifyingGlassPlusIcon"
        remixicon="RiZoomInLine"
        tabler="IconZoomIn"
      />
    ),
  },
  increment: "Zoom in",
};

/** How the zoom is shown: as a share of the fitted view. */
const PERCENT: Intl.NumberFormatOptions = {
  maximumFractionDigits: 0,
  style: "percent",
};

const bars = (count: number) => count * BAR;

const TRACKS: ArrangementTrack[] = [
  { color: 0, id: "drums", name: "Drums" },
  { color: 1, id: "bass", kind: "midi", name: "Bass" },
  { color: 2, id: "keys", kind: "midi", name: "Keys" },
  { color: 3, id: "vocals", name: "Vocals" },
] as const;

/** What a track meter shows while nothing plays: one channel. */
const SILENCE_MONO = {
  getSnapshot: () => ({ levels: [-60], peaks: [-60] }),
  subscribe: () => () => undefined,
};

/** What the master meter shows while nothing plays. */
const SILENCE = {
  getSnapshot: () => ({ levels: [-60, -60], peaks: [-60, -60] }),
  subscribe: () => () => undefined,
};

type Span = readonly [number, number];

const arranged = (
  id: string,
  name: string,
  track: string,
  from: number,
  to: number
): Arranged => ({
  id,
  name,
  sourceLength: bars(to - from + 2),
  track,
  value: {
    fadeIn: 0,
    fadeOut: 0,
    length: bars(to - from),
    offset: 0,
    start: bars(from),
  },
});

const BEAT = BAR / 4;

/** Notes from `[pitch, beat, beats]`: where each starts and how long it lasts, in beats of the first tempo. */
const notesOf = (pattern: readonly (readonly [number, number, number])[]) =>
  pattern.map(([pitch, at, beats], index) => ({
    id: `n${index}`,
    length: beats * BEAT,
    pitch,
    start: at * BEAT,
    velocity: 0.8,
  }));

/** Two bars of bass: A, then F, on the beat and the off-beats around it. */
const BASSLINE = notesOf([
  [33, 0, 0.75],
  [33, 1.5, 0.5],
  [45, 2, 0.5],
  [33, 3, 0.75],
  [29, 4, 0.75],
  [29, 5.5, 0.5],
  [41, 6, 0.5],
  [31, 7, 0.75],
]);

/** Four bars of chords, one a bar: A minor, F, C, G. */
const CHORDS = notesOf(
  [
    [57, 60, 64],
    [53, 57, 60],
    [55, 60, 64],
    [55, 59, 62],
  ].flatMap((chord, bar) =>
    chord.map((pitch) => [pitch, bar * 4, 3.75] as const)
  )
);

/** A MIDI clip that loops `notes` over `loopBars`, from bar `from` to bar `to`. */
const midiArranged = (
  id: string,
  name: string,
  track: string,
  from: number,
  to: number,
  loopBars: number,
  notes: Arranged["notes"]
): Arranged => ({
  id,
  loop: [0, bars(loopBars)],
  name,
  notes,
  sourceLength: bars(loopBars),
  track,
  value: {
    fadeIn: 0,
    fadeOut: 0,
    length: bars(to - from),
    offset: 0,
    start: bars(from),
  },
});

const CLIPS: Arranged[] = [
  arranged("d1", "Intro", "drums", 0, 4),
  arranged("d2", "Groove", "drums", 4, 16),
  arranged("d3", "Groove", "drums", 16, 28),
  arranged("d4", "Fill", "drums", 28, 32),
  midiArranged("b1", "Sub", "bass", 4, 12, 2, BASSLINE),
  midiArranged("b2", "Sub", "bass", 16, 28, 2, BASSLINE),
  midiArranged("k1", "Pad", "keys", 0, 8, 4, CHORDS),
  midiArranged("k2", "Chords", "keys", 8, 16, 4, CHORDS),
  midiArranged("k3", "Pad", "keys", 20, 32, 4, CHORDS),
  arranged("v1", "Verse", "vocals", 8, 16),
  arranged("v2", "Hook", "vocals", 16, 24),
];

function Row({
  children,
  className,
  height,
  lead,
  onResize,
  selected,
  trackId,
}: {
  children: ReactNode;
  className?: string;
  /** A height set by hand: the row keeps it instead of sharing what is left. */
  height?: number;
  lead?: ReactNode;
  onResize?: (trackId: string, height: number | undefined) => void;
  selected?: boolean;
  trackId?: string;
}) {
  const handleHeight = useCallback(
    (next: number | undefined) => {
      if (trackId !== undefined) {
        onResize?.(trackId, next);
      }
    },
    [onResize, trackId]
  );
  return (
    <TimelineTrack
      className={className}
      height={height}
      maxHeight={MAX_TRACK_HEIGHT}
      minHeight={MIN_TRACK_HEIGHT}
      onHeightChange={handleHeight}
      selected={selected}
      step={TRACK_HEIGHT_STEP}
    >
      <TimelineTrackHeader className="relative flex items-stretch">
        {lead}
      </TimelineTrackHeader>
      <div className="flex flex-1 flex-col">{children}</div>
    </TimelineTrack>
  );
}

/** Adaptive follows the zoom; the others stay put however far you zoom. */
const DIVISIONS: { label: string; value: GridDivision }[] = [
  { label: "Grid: Adaptive", value: "adaptive" },
  ...GRID_DIVISIONS.map((entry) => ({
    label:
      "bars" in entry
        ? `Grid: ${entry.bars} ${entry.bars === 1 ? "bar" : "bars"}`
        : `Grid: ${entry.id}`,
    value: entry.id as GridDivision,
  })),
];

/** What the keyboard does, for the shortcuts popover: the keys, then what they do. */
const SHORTCUTS: { action: string; keys: string[] }[] = [
  { action: "Split at the cursor", keys: ["S"] },
  { action: "Split at the cursor", keys: ["Cmd/Ctrl", "E"] },
  { action: "Duplicate the clip", keys: ["Cmd/Ctrl", "D"] },
  { action: "Remove the clip, or the tracks picked", keys: ["Delete"] },
  { action: "Add a track to the tracks picked", keys: ["Shift", "Click"] },
  { action: "Duplicate the tracks picked", keys: ["Cmd/Ctrl", "D"] },
  { action: "Add a locator at the cursor", keys: ["M"] },
  { action: "Loop the selection", keys: ["Cmd/Ctrl", "L"] },
  { action: "Rename a locator", keys: ["Enter"] },
  {
    action: "Jump to the previous or next locator",
    keys: ["Alt", "Left", "Right"],
  },
  { action: "Nudge a clip or an edge", keys: ["Left", "Right"] },
  { action: "Nudge further", keys: ["Shift", "Left", "Right"] },
  { action: "Copy a clip while dragging", keys: ["Alt", "Drag"] },
  { action: "Move or trim off the grid", keys: ["Cmd/Ctrl", "Drag"] },
  { action: "Zoom around the pointer", keys: ["Cmd/Ctrl", "Scroll"] },
  { action: "Resize a track", keys: ["Up", "Down"] },
  { action: "Add a clip to the selection", keys: ["Shift", "Click"] },
  { action: "Undo", keys: ["Cmd/Ctrl", "Z"] },
  { action: "Redo", keys: ["Cmd/Ctrl", "Shift", "Z"] },
  { action: "Select every clip", keys: ["Cmd/Ctrl", "A"] },
  { action: "Show or hide the automation", keys: ["A"] },
  { action: "Select by dragging a rectangle", keys: ["Shift", "Drag"] },
  { action: "Clear the selection", keys: ["Esc"] },
];

/** What a piece of a clip adds to its id: the recording is the id before it. */
const LEADING_ID = /-.*$/;

/** How many peaks the clip panel draws of a source: enough to zoom well in before they turn to blocks. */
const PEAKS = 4096;

/**
 * A stand-in for the peaks of a clip's recording, which the block does not
 * have: a shape of its own for each recording (the clip's id before any piece
 * suffix), drawn over the stretch of it the clip's source holds.
 */
function standInPeaks(clip: Arranged): number[] {
  const seed = [...clip.id.replace(LEADING_ID, "")].reduce(
    (sum, char) => sum + char.charCodeAt(0),
    0
  );
  const from = clip.sourceFrom ?? 0;
  return Array.from({ length: PEAKS }, (_, index) => {
    const time = from + (index / (PEAKS - 1)) * clip.sourceLength;
    const swell = 0.55 + 0.3 * Math.sin(time * 0.9 + seed);
    const beat = 0.6 + 0.4 * Math.abs(Math.cos(time * Math.PI));
    const grain = Math.abs(
      (Math.sin((time + seed) * 12.9898) * 43_758.5453) % 1
    );
    return Math.min(1, swell * beat * (0.5 + 0.5 * grain) + 0.05);
  });
}

/** Closes the clip panel. */
function CloseClipView({ onClose }: { onClose: () => void }) {
  return (
    <Button
      aria-label="Close the clip panel"
      onClick={onClose}
      size="icon-sm"
      variant="ghost"
    >
      <IconPlaceholder
        aria-hidden="true"
        className="size-4"
        hugeicons="Cancel01Icon"
        lucide="XIcon"
        phosphor="XIcon"
        remixicon="RiCloseLine"
        tabler="IconX"
      />
    </Button>
  );
}

/** The clip panel stays in view at the bottom of the window while the tracks scroll past above it. */
const DOCKED = "sticky bottom-0 z-40 shadow-lg";

/** The clip panel under the tracks, for the clip being edited, or a word on how to pick one. */
function ClipDock({
  bar,
  beatsPerBar,
  clip,
  collapsed,
  format,
  midi,
  onActiveChange,
  onClose,
  onCollapsedChange,
  onCrop,
  onValueChange,
  onValueCommitted,
  peaks,
  foldedBoxes,
  onFoldedBoxesChange,
  onRename,
}: {
  bar: number;
  beatsPerBar: number;
  clip: Arranged | undefined;
  /** Whether the panel is collapsed to its title: held here, so it stays so from clip to clip. */
  collapsed: boolean;
  format: (seconds: number) => string;
  /** What a MIDI clip's panel edits and sounds. */
  midi: Pick<
    ComponentProps<typeof MidiClipPanel>,
    "onNoteOff" | "onNoteOn" | "onNotesChange" | "onRegionChange"
  >;
  onActiveChange: (active: boolean) => void;
  onClose: () => void;
  onCollapsedChange: (collapsed: boolean) => void;
  onCrop: () => void;
  onValueChange: (next: ClipSettings) => void;
  onValueCommitted: () => void;
  /** The ids of the boxes folded in the panel: held here, so they stay folded from clip to clip. */
  foldedBoxes: readonly string[];
  onFoldedBoxesChange: (folded: string[]) => void;
  onRename: (id: string, name: string) => void;
  peaks: number[];
}) {
  const rename = useCallback(
    (name: string) => clip && onRename(clip.id, name),
    [clip, onRename]
  );
  if (!clip) {
    return null;
  }
  if (clip.notes) {
    return (
      <MidiClipPanel
        active={!clip.disabled}
        bar={bar}
        beat={bar / beatsPerBar}
        className={DOCKED}
        collapsed={collapsed}
        foldedBoxes={foldedBoxes}
        key={clip.id}
        name={clip.name}
        notes={clip.notes}
        onActiveChange={onActiveChange}
        onCollapsedChange={onCollapsedChange}
        onFoldedBoxesChange={onFoldedBoxesChange}
        onNotesCommitted={onValueCommitted}
        onRename={rename}
        region={{
          length: clip.value.length,
          loop: clip.loop ?? null,
          offset: clip.value.offset,
          sourceLength: clip.sourceLength,
        }}
        {...midi}
      >
        <CloseClipView onClose={onClose} />
      </MidiClipPanel>
    );
  }
  return (
    <ClipPanel
      active={!clip.disabled}
      bar={bar}
      beatsPerBar={beatsPerBar}
      className={DOCKED}
      collapsed={collapsed}
      foldedBoxes={foldedBoxes}
      format={format}
      name={clip.name}
      onActiveChange={onActiveChange}
      onCollapsedChange={onCollapsedChange}
      onCrop={onCrop}
      onFoldedBoxesChange={onFoldedBoxesChange}
      onRename={rename}
      onValueChange={onValueChange}
      onValueCommitted={onValueCommitted}
      value={{
        gain: clip.gain ?? 0,
        length: clip.value.length,
        loop: clip.loop ?? null,
        offset: clip.value.offset,
        reversed: Boolean(clip.reversed),
        sourceLength: clip.sourceLength,
        transpose: clip.transpose ?? 0,
        warp: clip.warp ?? null,
      }}
      values={peaks}
    >
      <CloseClipView onClose={onClose} />
    </ClipPanel>
  );
}

/** A track's level, from a stand-in source: the block plays nothing, so it moves while the track would be heard. */
function TrackLevel({ active, name }: { active: boolean; name: string }) {
  const simulated = useSimulatedLevels(1);
  return (
    <Meter
      aria-label={`${name} level`}
      className="data-[orientation=vertical]:h-full [--meter-thickness:--spacing(2)]"
      orientation="vertical"
      peakMarks="hidden"
      size="sm"
      source={active ? simulated : SILENCE_MONO}
      warnThreshold={-12}
    />
  );
}

/**
 * An arrangement: a bar ruler that seeks on the grid, a loop strip, a lane per
 * track with clips to move between tracks, trim and fade, a cursor to put
 * anywhere on a track and split at, and a playhead following a transport
 * clock. Cmd/Ctrl + scroll zooms around the pointer, and the bar below scrolls
 * and zooms. Made to be opened at full size, where the tracks take the screen.
 */
export function BlockTimeline() {
  const [tempo, setTempo] = useState<Tempo>(DEFAULT_TEMPO);
  const bar = secondsPerBar(tempo);
  const duration = BARS * bar;
  // Time-signature changes along the song, in seconds; the bars, the ruler and the position follow them.
  const [signatures, setSignatures] = useState<Signature[]>([]);
  const [editingSignature, setEditingSignature] = useState<string | null>(null);
  const signatureCount = useRef(0);
  const describe = useCallback(
    (seconds: number) => {
      const { bar: number, beat } = barsBeatsWith(seconds, tempo, signatures);
      return `Bar ${number}, beat ${beat}`;
    },
    [signatures, tempo]
  );
  const [loop, setLoop] = useState<Span>([bars(8), bars(16)]);
  const [looping, setLooping] = useState(true);
  // A stretch of time on some tracks, drawn by dragging across bare lanes, and what was last copied.
  const [timeSelection, setTimeSelection] = useState<TimeSelection | null>(
    null
  );
  const clipboard = useRef<Clipping | null>(null);
  // The track a press on bare lane began on: a time selection spans from it to the track under the pointer.
  const pressTrackRef = useRef<string | null>(null);
  // Locators: named points to jump to, kept in seconds at the tempo they were set.
  const [locators, setLocators] = useState<Locator[]>([
    { at: bars(4), name: "Verse" },
    { at: bars(20), name: "Chorus" },
  ]);
  const [metronome, setMetronome] = useState(false);
  const [rhythm, setRhythm] = useState<MetronomeRhythm>("auto");
  const { clock, playing, setPlaying } = useSimulatedClock({
    duration,
    loop,
    looping,
  });
  // The timeline fits the width it is given until it is zoomed.
  const viewportRef = useRef<HTMLDivElement>(null);
  const [fitPx, setFitPx] = useState(0);
  // Whether the bar has room for the zoom control; the menu lists the zoom steps only when it does not.
  const zoomSlotRef = useRef<HTMLDivElement>(null);
  const [zoomInBar, setZoomInBar] = useState(true);
  // Below the bar's room, the tempo and the undo and redo move into the more-controls menu.
  const narrowProbeRef = useRef<HTMLDivElement>(null);
  const [narrow, setNarrow] = useState(false);
  useLayoutEffect(() => {
    const element = viewportRef.current;
    if (!element) {
      return;
    }
    // The header column is narrower in a narrow panel: fit what is left beside it.
    const measure = () => {
      const lead =
        element.querySelector<HTMLElement>(
          '[data-slot="timeline-track-header"]'
        )?.offsetWidth ?? LEAD_PX;
      setFitPx(Math.max(0, (element.clientWidth - lead) / duration));
      if (zoomSlotRef.current) {
        setZoomInBar(getComputedStyle(zoomSlotRef.current).display !== "none");
      }
      if (narrowProbeRef.current) {
        setNarrow(getComputedStyle(narrowProbeRef.current).display !== "none");
      }
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [duration]);
  const [zoomed, setZoom] = useState<number | null>(null);
  const zoom = Math.max(zoomed ?? fitPx, fitPx) || 8;
  const getPosition = useCallback(() => clock.getSnapshot(), [clock]);
  const {
    addClip,
    addTrack,
    canRedo,
    canUndo,
    changeAutomation,
    changeClip,
    changeMix,
    clearSelection,
    clearTimeSelection,
    clipAction,
    clips,
    commitClip,
    consolidateTime,
    copying,
    copyOf,
    crop,
    editClip,
    fadeTimeSelection,
    duplicateSelection,
    duplicateTrack,
    folded,
    heights,
    insertTimeSpan,
    paste,
    toggleFold,
    toggleSelected,
    mixOf,
    redo,
    moveTrack,
    removeClip,
    removeTimeSpan,
    removeTrack,
    renameClip,
    renameTrack,
    setFadeShape,
    rescale,
    resizeTrack,
    selectAll,
    selectClip,
    selectClips,
    selectedIds,
    selectedTracks,
    selection,
    selectTrack,
    settleClipEdit,
    splitAtCursor,
    tracks,
    undo,
  } = useArrangement({
    colors: TRACK_COLORS.length,
    duration,
    getPosition,
    initialClips: CLIPS,
    initialTracks: TRACKS,
    splitStep: bar / 4,
  });
  const handleAddTrack = useCallback(() => addTrack(), [addTrack]);
  const handleAddMidiTrack = useCallback(
    () => addTrack(undefined, "midi"),
    [addTrack]
  );
  // Multi-select: a press on a clip adds it to the selection, and a drag on bare lane draws a rectangle. For touch, which has no Shift.
  const [multi, setMulti] = useState(false);
  // A touch screen has no Shift: in multi-select mode every press on a header adds to the tracks picked.
  const pickTrack = useCallback(
    (id: string, additive: boolean) => selectTrack(id, additive || multi),
    [multi, selectTrack]
  );
  const marquee = useMarquee(selectedIds, selectClips, multi);
  // A clip of a selection being dragged up or down, and how far: the rest of the selection follows it.
  const [lifting, setLifting] = useState<{ id: string; offset: number } | null>(
    null
  );
  const lift = useCallback(
    (id: string, offset: number) =>
      setLifting(offset === 0 ? null : { id, offset }),
    []
  );
  const [division, setDivision] = useState<GridDivision>("adaptive");
  const [density, setDensity] = useState<number>(24);
  const [triplet, setTriplet] = useState(false);
  const [snap, setSnap] = useState(true);
  // A stand-in for the mix's levels: the block plays nothing, so they move while it runs.
  const simulated = useSimulatedLevels(2);
  // What is heard: a muted track is silent, and while any track is soloed so is every other.
  const anySolo = tracks.some((track) => mixOf(track).soloed);
  const audible = useCallback(
    (trackId: string) => {
      const track = tracks.find((item) => item.id === trackId);
      if (!track) {
        return false;
      }
      const mix = mixOf(track);
      return !mix.muted && (!anySolo || mix.soloed);
    },
    [anySolo, mixOf, tracks]
  );
  // Automation mode: each track's lane shows the envelope of one of its parameters over its clips.
  const [automating, setAutomating] = useState(false);
  const [shownParameters, setShownParameters] = useState<
    Record<string, AutomatedParameter>
  >({});
  const showParameter = useCallback(
    (trackId: string, parameter: AutomatedParameter) =>
      setShownParameters((all) => ({ ...all, [trackId]: parameter })),
    []
  );
  // The automated parameters moved by hand: they play their mix until the automation is enabled again.
  const [overridden, setOverridden] = useState<
    Record<string, AutomatedParameter[]>
  >({});
  const anyOverridden = Object.values(overridden).some(
    (parameters) => parameters.length > 0
  );
  const reenableAutomation = useCallback(() => setOverridden({}), []);
  const toggleAutomating = useCallback(() => setAutomating((on) => !on), []);
  const changeTrackMix = useCallback(
    (trackId: string, patch: Partial<Mix>) => {
      const automation = tracks.find(
        (track) => track.id === trackId
      )?.automation;
      const moved = AUTOMATED_PARAMETERS.filter(
        (parameter) =>
          automation?.[parameter] &&
          (parameter === "volume" ? "gain" : "pan") in patch
      );
      if (moved.length > 0) {
        setOverridden((all) => ({
          ...all,
          [trackId]: [...new Set([...(all[trackId] ?? []), ...moved])],
        }));
      }
      changeMix(trackId, patch);
    },
    [changeMix, tracks]
  );
  // Drawing an envelope enables it again: what is drawn is what plays.
  const editAutomation = useCallback(
    (
      trackId: string,
      parameter: AutomatedParameter,
      points: readonly Breakpoint[]
    ) => {
      setOverridden((all) =>
        all[trackId]?.includes(parameter)
          ? {
              ...all,
              [trackId]: all[trackId].filter((item) => item !== parameter),
            }
          : all
      );
      changeAutomation(trackId, parameter, points);
    },
    [changeAutomation]
  );
  const clearAutomation = useCallback(
    (trackId: string, parameter: AutomatedParameter) => {
      changeAutomation(trackId, parameter, null);
      settleClipEdit();
    },
    [changeAutomation, settleClipEdit]
  );
  const levelsAt = useCallback<LevelsAt>(
    (trackId, time) => {
      const track = tracks.find((item) => item.id === trackId);
      return track
        ? mixAt(track, mixOf(track), time / duration, overridden[trackId])
        : { gain: 0, pan: 0 };
    },
    [duration, mixOf, overridden, tracks]
  );
  const midi = useMidiPlayback({
    audible,
    clips,
    clock,
    levelsAt,
    loop,
    looping,
    playing,
    tracks,
  });

  const playPause = useCallback(() => setPlaying((on) => !on), [setPlaying]);
  // Stop goes back to the top, as a DAW's does.
  const stop = useCallback(() => {
    setPlaying(false);
    clock.set(0);
  }, [clock, setPlaying]);
  // A tempo change keeps the musical positions: everything is rescaled to the new bar.
  const changeTempo = useCallback(
    (next: Tempo) => {
      // New bar over old: a clip three bars long stays three bars long.
      const ratio = secondsPerBar(next) / secondsPerBar(tempo);
      rescale(ratio);
      setLoop(([start, end]) => [start * ratio, end * ratio]);
      setSignatures((all) =>
        all.map((change) => ({ ...change, at: change.at * ratio }))
      );
      setZoom((current) => (current === null ? null : current / ratio));
      clock.set(clock.getSnapshot() * ratio);
      setTempo(next);
    },
    [clock, rescale, tempo]
  );
  // The zoom as a share of the fitted view: 100% shows everything.
  const zoomRatio = fitPx > 0 ? zoom / fitPx : 1;
  const maxRatio = fitPx > 0 ? MAX_ZOOM / fitPx : 1;
  const zoomPreset = ZOOM_PRESETS.find(
    (ratio) => Math.abs(ratio - zoomRatio) < 0.01
  );
  const changeZoom = useCallback(
    (ratio: number) => setZoom(ratio <= 1 ? null : ratio * fitPx),
    [fitPx]
  );
  // A track moves by dragging its grip, or with Up and Down on it.
  const trackOrder = useMemo(() => tracks.map((track) => track.id), [tracks]);
  const reorder = useTrackReorder({ onMove: moveTrack, order: trackOrder });
  const ghostTrack = tracks.find((track) => track.id === reorder.ghost?.id);
  const changeZoomPreset = useCallback(
    (value: string) => changeZoom(Number(value)),
    [changeZoom]
  );
  const handleDivisionValue = useCallback(
    (value: string) => setDivision(value as GridDivision),
    []
  );
  const seek = useCallback((seconds: number) => clock.set(seconds), [clock]);
  // A press on a track's empty lane puts the cursor there (the lane does it) and picks the track, letting go of the clips; on the surface under the last track it lets go of everything, as nothing is there.
  // The shortcuts are caught on the panel, so a press on the lane, which takes no focus, hands it over.
  const panelRef = useRef<HTMLDivElement>(null);
  // A press on any clip brings the closed clip panel back, even on a clip already picked, whose selection does not change.
  const showClipView = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if ((event.target as HTMLElement).closest("[data-clip-id]")) {
      setClipViewOpen(true);
    }
  }, []);
  const focusPanel = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      if (event.target === event.currentTarget && !event.shiftKey && !multi) {
        const { track } = event.currentTarget.dataset;
        pressTrackRef.current = track ?? null;
        setTimeSelection(null);
        if (track) {
          selectTrack(track);
        } else {
          clearSelection();
        }
        // `focusVisible` is missing from older DOM typings.
        panelRef.current?.focus({
          focusVisible: false,
          preventScroll: true,
        } as FocusOptions);
      }
    },
    [clearSelection, multi, selectTrack]
  );
  const addLocator = useCallback(() => {
    const at = clock.getSnapshot();
    setLocators((all) =>
      all.some((locator) => locator.at === at)
        ? all
        : [...all, { at, name: `Locator ${all.length + 1}` }]
    );
  }, [clock]);
  const jumpToLocator = useCallback((at: number) => clock.set(at), [clock]);
  const removeLocator = useCallback(
    (at: number) =>
      setLocators((all) => all.filter((locator) => locator.at !== at)),
    []
  );
  const renameLocator = useCallback(
    (at: number, name: string) =>
      setLocators((all) =>
        all.map((locator) =>
          locator.at === at ? { ...locator, name } : locator
        )
      ),
    []
  );

  /** Puts the loop around the selected clip, or around the clips of the selected track. */
  const loopSelection = useCallback(() => {
    const chosen = clips.filter(
      (clip) =>
        selectedIds.includes(clip.id) || selectedTracks.includes(clip.track)
    );
    if (chosen.length === 0) {
      return;
    }
    setLoop([
      Math.min(...chosen.map((clip) => clip.value.start)),
      Math.max(...chosen.map((clip) => clip.value.start + clip.value.length)),
    ]);
    setLooping(true);
  }, [clips, selectedIds, selectedTracks]);

  /** M adds a locator, Alt + arrows jump to the next locator or clip edge; returns whether the key was one of these. */
  const handleLocatorKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (
        event.key.toLowerCase() === "m" &&
        !(event.metaKey || event.ctrlKey || event.altKey)
      ) {
        event.preventDefault();
        addLocator();
        return true;
      }
      const direction = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
      if (!(event.altKey && direction)) {
        return false;
      }
      // The locators, and the edges of the clips on the picked tracks (on every track when none is picked).
      const edges = clips
        .filter(
          (clip) =>
            selectedTracks.length === 0 || selectedTracks.includes(clip.track)
        )
        .flatMap((clip) => [
          clip.value.start,
          clip.value.start + clip.value.length,
        ]);
      const target = adjacentPoint(
        [...locators.map((locator) => locator.at), ...edges],
        clock.getSnapshot(),
        direction === 1 ? 1 : -1
      );
      if (target === null) {
        return false;
      }
      event.preventDefault();
      event.stopPropagation();
      clock.set(target);
      return true;
    },
    [addLocator, clips, clock, locators, selectedTracks]
  );
  // The copies are there to be seen: bring them into view once they are drawn.
  const duplicateAndShow = useCallback(() => {
    const copies = duplicateSelection();
    requestAnimationFrame(() => {
      document
        .querySelector(`[data-clip-id="${copies[0]?.id}"]`)
        ?.scrollIntoView({ block: "nearest", inline: "nearest" });
    });
  }, [duplicateSelection]);
  /** Cmd/Ctrl + A selects every clip, Cmd/Ctrl + D duplicates the selection; returns whether the key was one of these. */
  const handleSelectionKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const key = event.key.toLowerCase();
      if (!(event.metaKey || event.ctrlKey)) {
        return false;
      }
      if (key === "z" || key === "y") {
        event.preventDefault();
        if (key === "y" || event.shiftKey) {
          redo();
        } else {
          undo();
        }
        return true;
      }
      if (key === "a") {
        event.preventDefault();
        selectAll();
        return true;
      }
      if (key === "d" && selectedIds.length > 0) {
        event.preventDefault();
        duplicateAndShow();
        return true;
      }
      if (key === "d" && selectedTracks.length > 0) {
        event.preventDefault();
        for (const id of selectedTracks) {
          duplicateTrack(id);
        }
        return true;
      }
      return false;
    },
    [
      duplicateAndShow,
      duplicateTrack,
      redo,
      selectAll,
      selectedIds,
      selectedTracks,
      undo,
    ]
  );
  /** Delete takes away the tracks picked by their headers (a clip handles its own Delete); returns whether the key was it. */
  const handleTrackKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const del = event.key === "Delete" || event.key === "Backspace";
      if (!(del && selectedTracks.length > 0 && selectedIds.length === 0)) {
        return false;
      }
      event.preventDefault();
      removeTrack(selectedTracks[0] ?? "");
      return true;
    },
    [removeTrack, selectedIds.length, selectedTracks]
  );
  /** A drag across bare lanes selects time: from the track it began on to the track under the pointer. */
  const selectTime = useCallback(
    (
      range: readonly [number, number],
      details: TimelinePrimitive.Lane.SelectionChangeEventDetails
    ) => {
      const order = tracks.map((track) => track.id);
      const first = order.indexOf(pressTrackRef.current ?? "");
      if (first === -1) {
        return;
      }
      const under = document
        .elementFromPoint(details.event.clientX, details.event.clientY)
        ?.closest<HTMLElement>("[data-track]")?.dataset.track;
      const last = under ? order.indexOf(under) : -1;
      const [top, bottom] =
        last === -1
          ? [first, first]
          : [Math.min(first, last), Math.max(first, last)];
      setTimeSelection({ range, tracks: order.slice(top, bottom + 1) });
    },
    [tracks]
  );
  /** Picking a clip lets go of the time selection. */
  // Picking a clip shows it in the clip panel, as a DAW's detail view follows the selection.
  const [clipViewOpen, setClipViewOpen] = useState(true);
  // Collapsed to its title, the panel leaves the tracks the room, from clip to clip.
  const [clipViewCollapsed, setClipViewCollapsed] = useState(false);
  const [clipBoxesFolded, setClipBoxesFolded] = useState<string[]>([]);
  const pickClip = useCallback(
    (id: string, additive: boolean) => {
      setTimeSelection(null);
      setClipViewOpen(true);
      selectClip(id, additive);
    },
    [selectClip]
  );
  // The clip panel, under the tracks: it edits the first selected clip.
  const editedClip = clips.find((clip) => clip.id === selectedIds[0]);
  const editedPeaks = useMemo(
    () => (editedClip ? standInPeaks(editedClip) : []),
    [editedClip]
  );
  const handleClipAction = useCallback(
    (action: ClipAction, id: string) => {
      if (action === "edit") {
        pickClip(id, false);
        setClipViewOpen(true);
      } else {
        clipAction(action, id);
      }
    },
    [clipAction, pickClip]
  );
  // A MIDI clip's notes, from the clip panel's piano roll: a drag is one undo step.
  const changeNotes = useCallback(
    (notes: NonNullable<Arranged["notes"]>) => {
      if (editedClip) {
        editClip(editedClip.id, (clip) => ({ ...clip, notes }));
      }
    },
    [editClip, editedClip]
  );
  // A double-click on bare MIDI lane makes an empty clip a bar long there, looping, and opens it.
  const midiClipCount = useRef(0);
  const addMidiClipAt = useCallback(
    (event: MouseEvent<HTMLDivElement>) => {
      const lane = event.currentTarget;
      const trackId = lane.dataset.track;
      if (
        !trackId ||
        (event.target as HTMLElement).closest(
          "[data-clip-id], [data-slot=automation-envelope]"
        )
      ) {
        return;
      }
      const rect = lane.getBoundingClientRect();
      const time = ((event.clientX - rect.left) / rect.width) * duration;
      const start = Math.max(
        0,
        Math.min(Math.floor(time / bar) * bar, duration - bar)
      );
      midiClipCount.current += 1;
      addClip({
        id: `midi-${midiClipCount.current}`,
        loop: [0, bar],
        name: "MIDI clip",
        notes: [],
        sourceLength: bar,
        track: trackId,
        value: { fadeIn: 0, fadeOut: 0, length: bar, offset: 0, start },
      });
      setTimeSelection(null);
      setClipViewOpen(true);
    },
    [addClip, bar, duration]
  );
  const changeClipSettings = useCallback(
    (next: ClipSettings) => {
      if (!editedClip) {
        return;
      }
      editClip(editedClip.id, (clip) => {
        // The clip keeps its place: what it plays changes, so it ends as early as the timeline needs.
        const length = Math.min(next.length, duration - clip.value.start);
        const fadeIn = Math.min(clip.value.fadeIn, length);
        return {
          ...clip,
          gain: next.gain,
          loop: next.loop ?? undefined,
          reversed: next.reversed,
          transpose: next.transpose,
          warp: next.warp ?? undefined,
          value: {
            ...clip.value,
            fadeIn,
            fadeOut: Math.min(clip.value.fadeOut, length - fadeIn),
            length,
            offset: next.offset,
          },
        };
      });
    },
    [duration, editClip, editedClip]
  );
  // Where a MIDI clip plays, from its clip panel: it keeps its place, and ends where the timeline does at most.
  const changeMidiRegion = useCallback(
    (region: MidiClipRegion) => {
      if (!editedClip) {
        return;
      }
      editClip(editedClip.id, (clip) => ({
        ...clip,
        loop: region.loop ?? undefined,
        value: {
          ...clip.value,
          length: Math.min(region.length, duration - clip.value.start),
          offset: region.offset,
        },
      }));
    },
    [duration, editClip, editedClip]
  );
  const midiEditing = useMemo(
    () => ({
      onNoteOff: midi.onNoteOff,
      onNoteOn: midi.onNoteOn,
      onNotesChange: changeNotes,
      onRegionChange: changeMidiRegion,
    }),
    [changeMidiRegion, changeNotes, midi.onNoteOff, midi.onNoteOn]
  );
  // The clip panel's switch: off, the clip stays on its track and is not heard.
  const setEditedActive = useCallback(
    (active: boolean) => {
      if (editedClip) {
        editClip(editedClip.id, (clip) => ({ ...clip, disabled: !active }));
        settleClipEdit();
      }
    },
    [editClip, editedClip, settleClipEdit]
  );
  const cropEdited = useCallback(() => {
    if (editedClip) {
      crop(editedClip.id);
    }
  }, [crop, editedClip]);
  const closeClipView = useCallback(() => setClipViewOpen(false), []);
  /** Where a paste lands: the picked track, the top of the time selection, the track of a picked clip, or the first track. */
  const pasteTrack = () =>
    selectedTracks[0] ??
    timeSelection?.tracks[0] ??
    clips.find((clip) => selectedIds.includes(clip.id))?.track ??
    tracks[0]?.id;
  /** Cmd/Ctrl + D: the time selection again right after itself, which the selection moves onto; with Shift, time is opened for it first. */
  const duplicateTime = (time: TimeSelection, opening: boolean) => {
    const copied = copyOf(time);
    const [from, to] = time.range;
    if (opening) {
      insertTimeSpan(to, to - from);
    }
    paste(copied, to, time.tracks[0] ?? "");
    setTimeSelection({ ...time, range: [to, to + (to - from)] });
  };
  /** Cmd/Ctrl + V: the last copy at the cursor; with Shift, time is opened for it first. */
  const pasteClipboard = (opening: boolean) => {
    const copied = clipboard.current;
    if (!copied) {
      return;
    }
    const at = clock.getSnapshot();
    if (opening) {
      insertTimeSpan(at, copied.length);
    }
    paste(copied, at, pasteTrack() ?? "");
  };
  /** Cmd/Ctrl + X: copies, then empties the time selection (takes the time out, with Shift), or removes the selected clips. */
  const cutSelection = (removing: boolean) => {
    clipboard.current = copyOf(timeSelection);
    if (timeSelection && removing) {
      removeTimeSpan(...timeSelection.range);
      setTimeSelection(null);
    } else if (timeSelection) {
      clearTimeSelection(timeSelection);
    } else if (selectedIds[0]) {
      removeClip(selectedIds[0]);
    }
  };
  /** What Cmd/Ctrl + a letter does to the time selection, the clipboard or the cursor, or undefined when it does nothing here. */
  const timeCommand = (key: string, shift: boolean) => {
    const length = timeSelection
      ? timeSelection.range[1] - timeSelection.range[0]
      : bar;
    const time = timeSelection;
    const commands: Record<string, (() => void) | undefined> = {
      c: () => {
        clipboard.current = copyOf(time);
      },
      d: time ? () => duplicateTime(time, shift) : undefined,
      i: () => insertTimeSpan(clock.getSnapshot(), length),
      j: time ? () => consolidateTime(time) : undefined,
      l: time
        ? () => {
            setLoop([...time.range]);
            setLooping(true);
          }
        : undefined,
      v: clipboard.current ? () => pasteClipboard(shift) : undefined,
      x: () => cutSelection(shift),
    };
    return commands[key];
  };
  /** What Delete does: empties the time selection, or with Cmd/Ctrl + Shift takes the time out of every track. */
  const deleteCommand = (event: KeyboardEvent<HTMLDivElement>) => {
    const time = timeSelection;
    if (!time) {
      return;
    }
    if (event.metaKey || event.ctrlKey) {
      return event.shiftKey
        ? () => {
            removeTimeSpan(...time.range);
            setTimeSelection(null);
          }
        : undefined;
    }
    return () => clearTimeSelection(time);
  };
  /**
   * Cmd/Ctrl + Alt + 3 shows or hides the clip panel, and Cmd/Ctrl + Alt + F
   * makes fades from the time selection. Read from the key's place: Alt
   * changes the letter on a Mac. Returns whether the key was one of these.
   */
  const handleAltCommand = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!((event.metaKey || event.ctrlKey) && event.altKey)) {
      return false;
    }
    if (event.code === "Digit3") {
      setClipViewOpen((open) => !open);
    } else if (event.code === "KeyF" && timeSelection) {
      fadeTimeSelection(timeSelection);
    } else {
      return false;
    }
    event.preventDefault();
    return true;
  };
  /**
   * Cmd/Ctrl + C and X copy and cut the time selection (the selected clips
   * when there is none), V pastes at the cursor, I opens a stretch of silence
   * there; with a time selection, D duplicates it after itself, J makes one
   * clip per track of it and L loops it. With Shift too, X, V, D and Delete
   * work on the time itself, on every track. Delete empties the time
   * selection, and 0 switches the selected clips off or on. Cmd/Ctrl + Alt + F
   * fades the clips whose edges the time selection covers, crossfading two that
   * meet. Returns whether the key was one of these.
   */
  const handleTimeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    // In a field, copying and deleting are the field's own.
    if (typing(event.target)) {
      return false;
    }
    if (handleAltCommand(event)) {
      return true;
    }
    if (event.altKey) {
      return false;
    }
    const key = event.key.toLowerCase();
    let command: (() => void) | undefined;
    if (event.key === "Delete" || event.key === "Backspace") {
      command = deleteCommand(event);
    } else if (event.metaKey || event.ctrlKey) {
      command = timeCommand(key, event.shiftKey);
    } else if (
      (key === "0" || event.code === "Digit0") &&
      selectedIds.length > 0
    ) {
      command = toggleSelected;
    }
    if (!command) {
      return false;
    }
    event.preventDefault();
    command();
    return true;
  };
  /** In automation mode, what a track shows: the parameter chooser in its header, and the envelope over its lane. */
  const automationOf = (track: ArrangementTrack) => {
    if (!automating) {
      return { chooser: null, envelope: null };
    }
    const parameter = shownParameters[track.id] ?? "volume";
    return {
      chooser: (
        <TrackAutomationChooser
          onClear={clearAutomation}
          onParameterChange={showParameter}
          parameter={parameter}
          track={track}
        />
      ),
      envelope: (
        <TrackAutomation
          describe={describe}
          duration={duration}
          mix={mixOf(track)}
          onChange={editAutomation}
          onCommit={settleClipEdit}
          overridden={overridden[track.id]}
          parameter={parameter}
          snapStep={snapStep}
          track={track}
        />
      ),
    };
  };
  /** A shows or hides the automation; returns whether the key was it. */
  const handleAutomationKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (
      event.key.toLowerCase() !== "a" ||
      event.metaKey ||
      event.ctrlKey ||
      event.altKey ||
      event.shiftKey ||
      typing(event.target)
    ) {
      return false;
    }
    event.preventDefault();
    toggleAutomating();
    return true;
  };
  // S, or Cmd/Ctrl + E, splits at the cursor, Cmd/Ctrl + D duplicates the clip; Escape drops the selection.
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      clearSelection();
      setTimeSelection(null);
      return;
    }
    const key = event.key.toLowerCase();
    if (
      handleTimeKey(event) ||
      handleTrackKey(event) ||
      handleLocatorKey(event) ||
      handleAutomationKey(event)
    ) {
      return;
    }
    if (key === "l" && (event.metaKey || event.ctrlKey) && selection) {
      event.preventDefault();
      loopSelection();
      return;
    }
    if (handleSelectionKey(event)) {
      return;
    }
    const split =
      (key === "s" && !(event.metaKey || event.ctrlKey || event.altKey)) ||
      (key === "e" && (event.metaKey || event.ctrlKey));
    if (split && selection) {
      event.preventDefault();
      splitAtCursor();
    }
  };

  // The grid the lanes draw is the grid everything snaps to.
  // Snap off draws no lines and snaps nothing: a step of 0 is no grid.
  const gridSeconds = gridInterval(division, zoom, tempo, {
    minSpacing: density,
    triplet,
  });
  const snapStep = snap ? gridSeconds : 0;
  const labelEvery = barLabelInterval(zoom, tempo);
  // Whole bars, counted rather than divided, so no tempo leaves a label like 6.9999.
  const labelBars = Math.max(1, Math.round(labelEvery / bar));
  // The bars through the signature changes: the ruler numbers every few, and hatches the ones a change cuts short.
  const songBars = useMemo(
    () => barsOf(tempo, signatures, duration),
    [duration, signatures, tempo]
  );
  const labels = songBars.filter((_, index) => index % labelBars === 0);

  /** Adds a signature change at the cursor, in the signature already in force there, and opens it to be typed. */
  const addSignature = useCallback(() => {
    const at = clock.getSnapshot();
    const [held] = signatures
      .filter((change) => change.at <= at)
      .sort((a, b) => b.at - a.at);
    signatureCount.current += 1;
    const id = `signature-${signatureCount.current}`;
    setSignatures((all) => [
      ...all.filter((change) => change.at !== at),
      {
        at,
        beatUnit: held?.beatUnit ?? tempo.beatUnit,
        beatsPerBar: held?.beatsPerBar ?? tempo.beatsPerBar,
        id,
      },
    ]);
    setEditingSignature(id);
  }, [clock, signatures, tempo]);
  const setSignature = useCallback(
    (id: string, beatsPerBar: number, beatUnit: number) =>
      setSignatures((all) =>
        all.map((change) =>
          change.id === id ? { ...change, beatUnit, beatsPerBar } : change
        )
      ),
    []
  );
  const moveSignature = useCallback(
    (id: string, to: number) =>
      setSignatures((all) =>
        all.map((change) =>
          change.id === id
            ? { ...change, at: Math.min(duration, Math.max(0, to)) }
            : change
        )
      ),
    [duration]
  );
  const removeSignature = useCallback(
    (id: string) =>
      setSignatures((all) => all.filter((change) => change.id !== id)),
    []
  );

  // A dragged clip or loop is pulled onto the edges of the other clips, the locators and the loop.
  const snapPoints = useMemo(() => {
    if (!snap) {
      return;
    }
    const points = new Set<number>(loop);
    for (const clip of clips) {
      points.add(clip.value.start);
      points.add(clip.value.start + clip.value.length);
    }
    for (const locator of locators) {
      points.add(locator.at);
    }
    return [...points];
  }, [clips, locators, loop, snap]);

  /** A finer or wider grid: a denser or sparser adaptive grid, or the next fixed division. */
  const stepGrid = useCallback(
    (finer: boolean) => {
      if (division !== "adaptive") {
        setDivision(adjacentDivision(division, finer ? 1 : -1));
        return;
      }
      // Denser lines are closer together: finer halves how close they may be.
      const index = DENSITIES.indexOf(density as (typeof DENSITIES)[number]);
      const next = DENSITIES[index + (finer ? -1 : 1)];
      if (next !== undefined) {
        setDensity(next);
      }
    },
    [density, division]
  );
  /** Cmd/Ctrl + 1 to 5: a finer or wider grid, triplets, snapping, and a grid fixed where it is or following the zoom again. */
  const changeGrid = useCallback(
    (change: (typeof GRID_KEYS)[string]) => {
      if (change === "triplet") {
        setTriplet((on) => !on);
      } else if (change === "snap") {
        setSnap((on) => !on);
      } else if (change === "fixed") {
        setDivision(
          division === "adaptive"
            ? divisionAt(gridSeconds / (triplet ? TRIPLET_FACTOR : 1), tempo)
            : "adaptive"
        );
      } else {
        stepGrid(change === "finer");
      }
    },
    [division, gridSeconds, stepGrid, tempo, triplet]
  );

  // Z zooms onto the selection, + and - zoom around the cursor, X steps back through the zooms they left behind.
  const zoomHistory = useRef<{ scroll: number; zoom: number | null }[]>([]);
  /** Zooms to `next` px per second, with `value` seconds `offset` px from the viewport's left edge. */
  const zoomOnto = useCallback(
    (next: number, value: number, offset: number) => {
      const element = viewportRef.current;
      if (!element) {
        return;
      }
      zoomHistory.current.push({ scroll: element.scrollLeft, zoom: zoomed });
      const clamped = Math.min(MAX_ZOOM, Math.max(fitPx, next));
      setZoom(clamped <= fitPx ? null : clamped);
      // After the timeline has drawn the new zoom, which keeps its middle still.
      requestAnimationFrame(() => {
        element.scrollLeft = leadWidth(element) + value * clamped - offset;
      });
    },
    [fitPx, zoomed]
  );
  const zoomAroundCursor = useCallback(
    (factor: number) => {
      const element = viewportRef.current;
      if (!element) {
        return;
      }
      const lead = leadWidth(element);
      const at = clock.getSnapshot();
      const x = lead + at * zoom - element.scrollLeft;
      // A cursor out of view gives way to the middle of the view.
      const offset =
        x >= lead && x <= element.clientWidth
          ? x
          : (lead + element.clientWidth) / 2;
      const value =
        x >= lead && x <= element.clientWidth
          ? at
          : (element.scrollLeft + offset - lead) / zoom;
      zoomOnto(zoom * factor, value, offset);
    },
    [clock, zoom, zoomOnto]
  );
  const zoomToSelection = useCallback(() => {
    const element = viewportRef.current;
    const chosen = clips.filter(
      (clip) =>
        selectedIds.includes(clip.id) || selectedTracks.includes(clip.track)
    );
    const [start, end] =
      chosen.length > 0
        ? [
            Math.min(...chosen.map((clip) => clip.value.start)),
            Math.max(
              ...chosen.map((clip) => clip.value.start + clip.value.length)
            ),
          ]
        : loop;
    if (!element || end <= start) {
      return;
    }
    const lead = leadWidth(element);
    zoomOnto((element.clientWidth - lead) / (end - start), start, lead);
  }, [clips, loop, selectedIds, selectedTracks, zoomOnto]);
  const zoomBack = useCallback(() => {
    const element = viewportRef.current;
    const previous = zoomHistory.current.pop();
    if (!(element && previous)) {
      return;
    }
    setZoom(previous.zoom);
    requestAnimationFrame(() => {
      element.scrollLeft = previous.scroll;
    });
  }, []);

  /** Up and Down pick the track above or below the one picked, or the first or last. */
  const pickNextTrack = useCallback(
    (direction: 1 | -1) => {
      const current =
        selectedTracks[0] ??
        clips.find((clip) => selectedIds.includes(clip.id))?.track;
      const index = tracks.findIndex((track) => track.id === current);
      const next =
        index === -1
          ? tracks.at(direction === 1 ? 0 : -1)
          : tracks[Math.min(tracks.length - 1, Math.max(0, index + direction))];
      if (next) {
        selectTrack(next.id);
      }
    },
    [clips, selectTrack, selectedIds, selectedTracks, tracks]
  );

  /** On the panel itself: arrows move the cursor on the grid, Home and End go to the ends of the arrangement. */
  const handleCursorKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) {
        return false;
      }
      const at = clock.getSnapshot();
      const lastEnd = Math.max(
        0,
        ...clips.map((clip) => clip.value.start + clip.value.length)
      );
      const step = gridSeconds;
      const targets: Record<string, number> = {
        ArrowLeft: snapMove(at - step, at, { offset: 0, step }),
        ArrowRight: snapMove(at + step, at, { offset: 0, step }),
        End: lastEnd,
        Home: 0,
      };
      const target = targets[event.key];
      if (target !== undefined) {
        event.preventDefault();
        clock.set(Math.min(duration, Math.max(0, target)));
        return true;
      }
      if (event.key === "ArrowUp" || event.key === "ArrowDown") {
        event.preventDefault();
        pickNextTrack(event.key === "ArrowDown" ? 1 : -1);
        return true;
      }
      return false;
    },
    [clips, clock, duration, gridSeconds, pickNextTrack]
  );

  /** The navigation keys: the grid with Cmd/Ctrl + 1 to 5, the zoom with +, -, Z and X, and the cursor from the panel. */
  const handleNavigationKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.metaKey || event.ctrlKey) {
        const change = GRID_KEYS[event.code];
        if (!(change && !event.altKey && !event.shiftKey)) {
          return false;
        }
        event.preventDefault();
        changeGrid(change);
        return true;
      }
      if (typing(event.target) || event.altKey) {
        return false;
      }
      const key = event.key.toLowerCase();
      const zoomKeys: Record<string, () => void> = {
        "+": () => zoomAroundCursor(2),
        "-": () => zoomAroundCursor(0.5),
        "=": () => zoomAroundCursor(2),
        x: zoomBack,
        z: zoomToSelection,
      };
      const zoomKey = event.shiftKey && key !== "+" ? undefined : zoomKeys[key];
      if (zoomKey) {
        event.preventDefault();
        zoomKey();
        return true;
      }
      // Anywhere else in the panel, a clip's or the loop's own input takes its arrows.
      return event.target === event.currentTarget && handleCursorKey(event);
    },
    [changeGrid, handleCursorKey, zoomAroundCursor, zoomBack, zoomToSelection]
  );
  const handlePanelKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!handleNavigationKey(event)) {
      handleKeyDown(event);
    }
  };

  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: the editor's shortcuts, caught from the controls inside it
    <div
      className="rounded-xl @container/panel flex w-full min-w-0 flex-col gap-4 border bg-card p-4 outline-none"
      onKeyDown={handlePanelKey}
      ref={panelRef}
      tabIndex={-1}
    >
      <div className="flex items-center justify-between gap-2 p-1">
        <div className="flex min-w-0 flex-1 items-center gap-2">
          <div className="@3xl/panel:hidden" ref={narrowProbeRef} />
          <Transport
            className="min-w-0 flex-1 flex-nowrap"
            looping={looping}
            metronome={metronome}
            onLoopingChange={setLooping}
            onMetronomeChange={setMetronome}
            onPlayPause={playPause}
            onRhythmChange={setRhythm}
            onStop={stop}
            onTempoChange={changeTempo}
            playing={playing}
            rhythm={rhythm}
            signatures={signatures}
            source={clock}
            tempo={tempo}
          />
          <div className="hidden h-7 w-10 shrink-0 items-center @3xl/panel:flex">
            <Meter
              aria-label="Master output level"
              className="data-[orientation=horizontal]:[--meter-thickness:--spacing(3)]"
              orientation="horizontal"
              peakMarks="hidden"
              size="sm"
              source={playing ? simulated : SILENCE}
              warnThreshold={-12}
            />
          </div>
        </div>
        <div className="flex min-w-0 items-center gap-2">
          {anyOverridden ? (
            <Button
              aria-label="Re-enable automation"
              className="text-primary"
              onClick={reenableAutomation}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="RotateLeft01Icon"
                lucide="RotateCcwIcon"
                phosphor="ArrowCounterClockwiseIcon"
                remixicon="RiArrowGoBackLine"
                tabler="IconRotate"
              />
            </Button>
          ) : null}
          <Button
            aria-label="Automation mode"
            aria-pressed={automating}
            className="hidden @3xl/panel:inline-flex"
            onClick={toggleAutomating}
            size="icon"
            variant={automating ? "default" : "outline"}
          >
            {/* An envelope, drawn as the selects draw their shapes: what automation draws over a lane. */}
            <svg
              aria-hidden="true"
              className="size-4"
              fill="none"
              stroke="currentColor"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              viewBox="0 0 24 24"
            >
              <path d="M2 17h4l5-10h4c3 0 4 10 7 10" />
            </svg>
          </Button>
          <ButtonGroup className="hidden @3xl/panel:flex">
            <Button
              aria-label="Undo"
              disabled={!canUndo}
              onClick={undo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Undo02Icon"
                lucide="Undo2Icon"
                phosphor="ArrowUUpLeftIcon"
                remixicon="RiArrowGoBackLine"
                tabler="IconArrowBackUp"
              />
            </Button>
            <Button
              aria-label="Redo"
              disabled={!canRedo}
              onClick={redo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Redo02Icon"
                lucide="Redo2Icon"
                phosphor="ArrowUUpRightIcon"
                remixicon="RiArrowGoForwardLine"
                tabler="IconArrowForwardUp"
              />
            </Button>
          </ButtonGroup>
          <div
            className="hidden items-center gap-2 @7xl/panel:flex"
            ref={zoomSlotRef}
          >
            <NumberControl
              format={PERCENT}
              label="Zoom"
              largeStep={1}
              max={maxRatio}
              min={1}
              onValueChange={changeZoom}
              step={0.25}
              steppers={ZOOM_STEPPERS}
              unit="Zoom"
              value={zoomRatio}
            />
          </div>
          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <Button
                  aria-label="More controls"
                  size="icon"
                  variant="outline"
                />
              }
            >
              <IconPlaceholder
                hugeicons="MoreHorizontalIcon"
                lucide="EllipsisIcon"
                phosphor="DotsThreeIcon"
                remixicon="RiMoreLine"
                tabler="IconDots"
              />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="w-auto min-w-48">
              {narrow ? (
                <>
                  <DropdownMenuItem disabled={!canUndo} onClick={undo}>
                    Undo
                  </DropdownMenuItem>
                  <DropdownMenuItem disabled={!canRedo} onClick={redo}>
                    Redo
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                </>
              ) : null}
              <DropdownMenuCheckboxItem
                checked={multi}
                onCheckedChange={setMulti}
              >
                Multi-select
              </DropdownMenuCheckboxItem>
              <DropdownMenuCheckboxItem
                checked={automating}
                onCheckedChange={setAutomating}
              >
                Automation
              </DropdownMenuCheckboxItem>
              <DropdownMenuSeparator />
              <DropdownMenuSub>
                <DropdownMenuSubTrigger>
                  Keyboard shortcuts
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="flex max-h-(--available-height) w-auto flex-col">
                  <div className="scrollbar flex min-h-0 flex-col gap-2 overflow-y-auto p-2 text-sm whitespace-nowrap">
                    {SHORTCUTS.map((shortcut) => (
                      <div
                        className="flex items-center justify-between gap-3"
                        key={`${shortcut.action}-${shortcut.keys.join("+")}`}
                      >
                        <span>{shortcut.action}</span>
                        <KbdGroup>
                          {shortcut.keys.map((key) => (
                            <Kbd key={key}>{key}</Kbd>
                          ))}
                        </KbdGroup>
                      </div>
                    ))}
                  </div>
                </DropdownMenuSubContent>
              </DropdownMenuSub>
              {zoomInBar ? null : (
                <>
                  <DropdownMenuSeparator />
                  <DropdownMenuSub>
                    <DropdownMenuSubTrigger>Zoom</DropdownMenuSubTrigger>
                    <DropdownMenuSubContent className="w-auto">
                      <div className="p-2">
                        <NumberControl
                          format={PERCENT}
                          label="Zoom"
                          largeStep={1}
                          max={maxRatio}
                          min={1}
                          onValueChange={changeZoom}
                          step={0.25}
                          steppers={ZOOM_STEPPERS}
                          unit="Zoom"
                          value={zoomRatio}
                        />
                      </div>
                      <DropdownMenuSeparator />
                      <DropdownMenuRadioGroup
                        onValueChange={changeZoomPreset}
                        value={
                          zoomPreset === undefined ? "" : String(zoomPreset)
                        }
                      >
                        {ZOOM_PRESETS.map((ratio) => (
                          <DropdownMenuRadioItem
                            disabled={ratio > maxRatio}
                            key={ratio}
                            value={String(ratio)}
                          >
                            {ratio === 1 ? "Fit" : `${ratio * 100} %`}
                          </DropdownMenuRadioItem>
                        ))}
                      </DropdownMenuRadioGroup>
                    </DropdownMenuSubContent>
                  </DropdownMenuSub>
                </>
              )}
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </div>
      <Timeline
        className="min-h-0 flex-1 gap-2"
        duration={duration}
        maxZoom={MAX_ZOOM}
        minZoom={fitPx || undefined}
        onPositionChange={seek}
        onZoomChange={setZoom}
        snapPoints={snapPoints}
        snapStep={snapStep}
        source={clock}
        zoom={zoomed === null ? undefined : zoom}
      >
        <TimelineNavigator getAriaValueText={describe} />
        <TimelineViewport
          className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-muted/40"
          ref={viewportRef}
          scrollbar="hidden"
        >
          {/* As wide as the lanes, so the stuck headers stay put however far it scrolls. */}
          <div
            className={cn(
              "flex min-h-full w-max min-w-full shrink-0 flex-col",
              marquee.rect && "select-none"
            )}
            {...marquee.handlers}
          >
            {/* One sticky block, so what is stuck stays stacked whatever each row measures. */}
            <div className="sticky top-0 z-40 flex min-w-full shrink-0 flex-col bg-background">
              <Row
                className="shrink-0 border-foreground/25 border-b"
                lead={
                  <span className="flex h-full items-center px-3 text-xs text-muted-foreground">
                    Loop
                  </span>
                }
              >
                <TimelineLane className="h-6 cursor-cell border-b-0">
                  <TimelineRange
                    aria-label="Loop"
                    drawable
                    getAriaValueText={describe}
                    onValueChange={setLoop}
                    value={loop}
                  >
                    <TimelineRangeHandle aria-label="Loop start" edge="start" />
                    <TimelineRangeHandle aria-label="Loop end" edge="end" />
                  </TimelineRange>
                  <TimelinePlayhead />
                </TimelineLane>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <DropdownMenu>
                    <DropdownMenuTrigger
                      render={
                        <Button
                          aria-label="Grid"
                          className="h-full w-full justify-start rounded-none border-0 tabular-nums"
                          variant="ghost"
                        />
                      }
                    >
                      {snap ? gridLabel(gridSeconds, tempo) : "Snap off"}
                    </DropdownMenuTrigger>
                    <DropdownMenuContent
                      align="start"
                      className="w-auto min-w-48"
                    >
                      <TimelineGridMenuItems
                        division={division}
                        divisions={DIVISIONS}
                        onDivisionChange={handleDivisionValue}
                        onSnapChange={setSnap}
                        onTripletChange={setTriplet}
                        snap={snap}
                        triplet={triplet}
                        variant="dropdown"
                      />
                    </DropdownMenuContent>
                  </DropdownMenu>
                }
              >
                <TimelineRuler
                  aria-label="Playhead"
                  className="border-b-0"
                  getAriaValueText={describe}
                >
                  {songBars
                    .filter((item) => item.partial)
                    .map((item) => (
                      <div
                        aria-hidden="true"
                        className="pointer-events-none absolute inset-y-0 bg-[repeating-linear-gradient(135deg,var(--muted-foreground)_0_1px,transparent_1px_6px)] opacity-30"
                        data-slot="timeline-partial-bar"
                        key={item.start}
                        style={{
                          left: `calc(var(--timeline-unit) * ${item.start})`,
                          width: `calc(var(--timeline-unit) * ${item.length})`,
                        }}
                      />
                    ))}
                  {labels.map((item) => (
                    <TimelineMark key={item.start} value={item.start}>
                      {item.number}
                    </TimelineMark>
                  ))}
                  <TimelinePlayhead />
                </TimelineRuler>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <span className="flex h-full items-center px-3 text-xs text-muted-foreground">
                    Markers
                  </span>
                }
              >
                <TimelineLane className="h-6 border-b-0">
                  {locators.map((locator) => (
                    <TimelineMark key={locator.at} value={locator.at}>
                      <TimelineLocator
                        locator={locator}
                        onJump={jumpToLocator}
                        onRemove={removeLocator}
                        onRename={renameLocator}
                      />
                    </TimelineMark>
                  ))}
                </TimelineLane>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <div className="flex h-full items-center justify-between ps-3 pe-1 text-muted-foreground text-xs">
                    Signature
                    <Button
                      aria-label="Add a time signature at the cursor"
                      className="size-5"
                      onClick={addSignature}
                      size="icon"
                      title="Add a time signature at the cursor"
                      variant="ghost"
                    >
                      <IconPlaceholder
                        aria-hidden="true"
                        className="size-3"
                        hugeicons="PlusSignIcon"
                        lucide="PlusIcon"
                        phosphor="PlusIcon"
                        remixicon="RiAddLine"
                        tabler="IconPlus"
                      />
                    </Button>
                  </div>
                }
              >
                <TimelineLane className="h-6 border-b-0">
                  {signatures.map((change) => (
                    <TimelineMark key={change.id} value={change.at}>
                      <TimelineSignature
                        change={change}
                        editing={editingSignature === change.id}
                        onEditingChange={setEditingSignature}
                        onJump={jumpToLocator}
                        onMove={moveSignature}
                        onRemove={removeSignature}
                        onSet={setSignature}
                        step={snapStep}
                      />
                    </TimelineMark>
                  ))}
                </TimelineLane>
              </Row>
            </div>
            {tracks.map((track) => (
              <div
                aria-label={track.name}
                className="contents"
                data-track-id={track.id}
                key={track.id}
                role="group"
              >
                <Row
                  className={cn(
                    "border-foreground/25 border-b",
                    heights[track.id] === undefined && "flex-1",
                    // The track being moved stays in place, faded; the line marks the gap it would land in.
                    reorder.draggingId === track.id && "opacity-50",
                    reorder.drop?.id === track.id &&
                      reorder.drop.edge === "top" &&
                      "shadow-[inset_0_2px_0_0_var(--primary)]",
                    reorder.drop?.id === track.id &&
                      reorder.drop.edge === "bottom" &&
                      "shadow-[inset_0_-2px_0_0_var(--primary)]"
                  )}
                  height={heights[track.id]}
                  lead={
                    <div className="flex min-w-0 flex-1">
                      <button
                        aria-label={`Move ${track.name}`}
                        className={cn(
                          "absolute inset-y-0 inset-s-1 z-10 flex w-3 cursor-grab items-center justify-center text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:outline-none active:cursor-grabbing",
                          reorder.draggingId === track.id &&
                            "cursor-grabbing bg-muted text-foreground"
                        )}
                        {...reorder.gripProps(track.id)}
                        type="button"
                      >
                        <svg
                          aria-hidden="true"
                          className="size-3.5"
                          fill="currentColor"
                          focusable="false"
                          viewBox="0 0 16 16"
                        >
                          <circle cx="5.5" cy="3.5" r="1.25" />
                          <circle cx="10.5" cy="3.5" r="1.25" />
                          <circle cx="5.5" cy="8" r="1.25" />
                          <circle cx="10.5" cy="8" r="1.25" />
                          <circle cx="5.5" cy="12.5" r="1.25" />
                          <circle cx="10.5" cy="12.5" r="1.25" />
                        </svg>
                      </button>
                      <TimelineTrackControls
                        compact={
                          heights[track.id] !== undefined &&
                          heights[track.id] < COMPACT_TRACK_HEIGHT
                        }
                        folded={folded[track.id]}
                        meter={
                          <TrackLevel
                            active={
                              playing &&
                              !mixOf(track).muted &&
                              (!anySolo || mixOf(track).soloed)
                            }
                            name={track.name}
                          />
                        }
                        mix={mixOf(track)}
                        name={track.name}
                        onDuplicate={duplicateTrack}
                        onInsert={addTrack}
                        onMix={changeTrackMix}
                        onRemove={removeTrack}
                        onRename={renameTrack}
                        onSelect={pickTrack}
                        onToggleFold={toggleFold}
                        selected={selectedTracks.includes(track.id)}
                        trackId={track.id}
                      >
                        {automationOf(track).chooser}
                      </TimelineTrackControls>
                    </div>
                  }
                  onResize={resizeTrack}
                  selected={selectedTracks.includes(track.id)}
                  trackId={track.id}
                >
                  <ContextMenu>
                    <ContextMenuTrigger render={<div className="contents" />}>
                      <TimelineLane
                        className={cn(
                          "h-auto flex-1 border-b-0",
                          heights[track.id] === undefined
                            ? "min-h-14"
                            : "min-h-0",
                          // A short track keeps its clips thin, so they fit.
                          heights[track.id] !== undefined &&
                            heights[track.id] < COMPACT_TRACK_HEIGHT &&
                            "**:data-clip-id:inset-y-0.5",
                          selectedTracks.includes(track.id) && "bg-muted/40",
                          // A silent track keeps its clips on the page, drained.
                          (mixOf(track).muted ||
                            (anySolo && !mixOf(track).soloed)) &&
                            "**:data-clip-id:opacity-40"
                        )}
                        data-track={track.id}
                        gridStep={snapStep}
                        onDoubleClick={
                          track.kind === "midi" ? addMidiClipAt : undefined
                        }
                        onPointerDown={focusPanel}
                        onPointerDownCapture={showClipView}
                        onSelectionChange={selectTime}
                        seekable
                      >
                        {copying
                          .filter((original) => original.track === track.id)
                          .map((original) => (
                            <ArrangedClipGhost
                              clip={original}
                              key={original.id}
                            />
                          ))}
                        {clips
                          .filter((clip) => clip.track === track.id)
                          .map((clip) => (
                            <ArrangedClip
                              clip={clip}
                              copying={copying.some(
                                (original) => original.id === clip.id
                              )}
                              describe={describe}
                              key={clip.id}
                              lift={
                                lifting &&
                                lifting.id !== clip.id &&
                                selectedIds.includes(clip.id) &&
                                selectedIds.includes(lifting.id)
                                  ? lifting.offset
                                  : 0
                              }
                              multi={multi}
                              onAction={handleClipAction}
                              onChange={changeClip}
                              onCommit={commitClip}
                              onFadeShape={setFadeShape}
                              onLift={lift}
                              onRemove={removeClip}
                              onRename={renameClip}
                              onSelect={pickClip}
                              selected={selectedIds.includes(clip.id)}
                              swatch={mixOf(track).color}
                              trackName={track.name}
                            />
                          ))}
                        {timeSelection?.tracks.includes(track.id) ? (
                          <div
                            aria-hidden="true"
                            className="pointer-events-none absolute inset-y-0 z-10 bg-primary/15 ring-1 ring-primary/40 ring-inset"
                            data-slot="timeline-time-selection"
                            style={{
                              left: `calc(var(--timeline-unit) * ${timeSelection.range[0]})`,
                              width: `calc(var(--timeline-unit) * ${timeSelection.range[1] - timeSelection.range[0]})`,
                            }}
                          />
                        ) : null}
                        {automationOf(track).envelope}
                        <TimelinePlayhead />
                      </TimelineLane>
                    </ContextMenuTrigger>
                    <ContextMenuContent className="w-auto min-w-48">
                      <TimelineGridMenuItems
                        division={division}
                        divisions={DIVISIONS}
                        onDivisionChange={handleDivisionValue}
                        onSnapChange={setSnap}
                        onTripletChange={setTriplet}
                        snap={snap}
                        triplet={triplet}
                      />
                    </ContextMenuContent>
                  </ContextMenu>
                </Row>
              </div>
            ))}
            <Row
              className="sticky bottom-0 z-30 border-foreground/25 border-b bg-background"
              lead={
                <div className="m-1 flex min-w-0 flex-1 gap-1">
                  <Button
                    className="min-w-0 flex-1 justify-start gap-1.5"
                    onClick={handleAddTrack}
                    size="sm"
                    variant="ghost"
                  >
                    <IconPlaceholder
                      hugeicons="PlusSignIcon"
                      lucide="PlusIcon"
                      phosphor="PlusIcon"
                      remixicon="RiAddLine"
                      tabler="IconPlus"
                    />
                    Audio
                  </Button>
                  <Button
                    className="min-w-0 flex-1 justify-start gap-1.5"
                    onClick={handleAddMidiTrack}
                    size="sm"
                    variant="ghost"
                  >
                    <IconPlaceholder
                      hugeicons="PlusSignIcon"
                      lucide="PlusIcon"
                      phosphor="PlusIcon"
                      remixicon="RiAddLine"
                      tabler="IconPlus"
                    />
                    MIDI
                  </Button>
                </div>
              }
            >
              <TimelineLane
                className="h-10 border-b-0 bg-background"
                gridStep={snapStep}
              />
            </Row>
            {/* The surface carries on to the bottom of the view, so the arrangement does not stop at the last track. */}
            <Row className="flex-1" lead={<div className="h-full" />}>
              <TimelineLane
                className="h-auto flex-1 border-b-0 bg-background"
                gridStep={snapStep}
                onPointerDown={focusPanel}
                seekable
              >
                <TimelinePlayhead />
              </TimelineLane>
            </Row>
          </div>
        </TimelineViewport>
        {marquee.rect
          ? // In the page's body: a dialog is transformed, which would make a fixed rectangle its own.
            createPortal(
              <div
                aria-hidden="true"
                className="pointer-events-none fixed z-50 border border-primary bg-primary/10"
                style={marquee.rect}
              />,
              document.body
            )
          : null}
      </Timeline>
      {clipViewOpen ? (
        <ClipDock
          bar={bar}
          beatsPerBar={tempo.beatsPerBar}
          clip={editedClip}
          collapsed={clipViewCollapsed}
          foldedBoxes={clipBoxesFolded}
          format={describe}
          midi={midiEditing}
          onActiveChange={setEditedActive}
          onClose={closeClipView}
          onCollapsedChange={setClipViewCollapsed}
          onCrop={cropEdited}
          onFoldedBoxesChange={setClipBoxesFolded}
          onRename={renameClip}
          onValueChange={changeClipSettings}
          onValueCommitted={settleClipEdit}
          peaks={editedPeaks}
        />
      ) : null}
      {reorder.ghost && ghostTrack
        ? createPortal(
            <div
              aria-hidden="true"
              className={cn(
                "pointer-events-none fixed z-50 flex items-center border border-s-4 bg-card px-3 text-sm shadow-lg",
                TRACK_COLORS[mixOf(ghostTrack).color]?.edge
              )}
              ref={reorder.ghostRef}
              style={{
                height: reorder.ghost.height,
                left: reorder.ghost.left,
                top: reorder.ghost.top,
                width: reorder.ghost.width,
              }}
            >
              {ghostTrack.name}
            </div>,
            document.body
          )
        : null}
    </div>
  );
}
```

An arrangement is tracks of clips along a song. The [Timeline](/docs/components/base/timeline) draws and moves them; the parts on this page hold the arrangement and fill in what a DAW's arrangement needs around it. Each is a file you own and installs on its own, and every part takes its state as values and reports changes through callbacks, so the arrangement can live in your own store and play through an engine of your own.

## Installation

```bash
npx shadcn@latest add @audio-ui/arrangement-editor
```

It installs every part below, with the timeline. Install a part alone by its name (`@audio-ui/arranged-clip`, `@audio-ui/use-track-reorder`…) to take only what you need. The preview above is `@audio-ui/block-timeline`, which wires them into a screen.

## The state

- `arrangement` (`npx shadcn@latest add @audio-ui/arrangement`) is the pure side: `lay` puts a clip on a track so it covers what is under it, `land` drops a dragged group (or its copies) on another track, `shiftTrack` and `otherSelected` are what a group drop needs, and `scaleValue` rescales a clip when the tempo changes. Tracks are passed as an order of ids, so nothing assumes how many there are.
- `use-arrangement` is the state: tracks, clips, selection, each track's mix and height, and every operation on them. It keeps undo and redo over tracks and clips together.
- `use-simulated-clock` is a clock for a timeline that makes no sound, as a live source that loops between two points (to start sound at a position, `use-transport-clock` schedules against the audio clock), and `use-marquee-selection` is the rectangle that selects the clips it touches.

```tsx
const arrangement = useArrangement({
  duration,
  getPosition: () => clock.getSnapshot(), // where a split cuts
  initialClips,
  initialTracks, // { id, name, color }[]
  splitStep: bar / 4, // the shortest a split piece may be
});

arrangement.changeClip; // Timeline.Clip's onValueChange, by id
arrangement.commitClip; // its onValueCommitted: lands a drag, on another track too
arrangement.addTrack();
arrangement.removeTrack(id); // with its clips; any track can go, the last included
arrangement.undo();
```

It also gives `selectClip`, `selectClips`, `selectAll`, `selectTrack`, `splitAtCursor`, `duplicateSelection`, `removeClip`, `renameClip`, `setFadeShape` (a fade's curve, as one undo step), `clipAction` (a clip's menu: duplicate, split, switch off, delete), `mixOf` and `changeMix` (gain, pan, mute, solo, color), `heights` and `resizeTrack`, `renameTrack`, `duplicateTrack(id)` (the track with its clips, placed after it), `folded` and `toggleFold` (a folded track is a strip of `foldedHeight` px that keeps its own height for when it opens), the time commands `clearTimeSelection`, `copyOf`, `paste`, `removeTimeSpan`, `insertTimeSpan` and `consolidateTime` (a time selection is `{ range, tracks }`, and `lib/arrangement` holds them as pure functions: `copyTime`, `clearTime`, `pasteTime`, `deleteTime`, `openTime`, `consolidate`), `fadeTimeSelection` and `crossfade` (fades from a time selection, and a fade where two clips meet given to both), `toggleSelected`, and `rescale(ratio)` for a tempo change, which also clears the history since it is not part of it.

## Clips

`ArrangedClip` (`npx shadcn@latest add @audio-ui/arranged-clip`) is a clip of the arrangement on its track's lane: drag its title bar to move it (with Alt, a copy), its edges to trim it into its source, its top corners to fade it, and pick a fade's curve from the menu of its handle. Double-click its title to rename it in place; right-click it for its menu (edit, rename, duplicate, split at the cursor, switch off, delete), which selects it first. A MIDI clip draws its notes across its body, and a looped or warped one stretches as far as its loop or its warp lets it.

```tsx
import { ArrangedClip, ArrangedClipGhost } from "@/components/audio-ui/arranged-clip";

<TimelineLane>
  {clips
    .filter((clip) => clip.track === track.id)
    .map((clip) => (
      <ArrangedClip
        clip={clip}
        describe={describe}
        key={clip.id}
        onAction={arrangement.clipAction}
        onChange={arrangement.changeClip}
        onCommit={arrangement.commitClip}
        onFadeShape={arrangement.setFadeShape}
        onRemove={arrangement.removeClip}
        onRename={arrangement.renameClip}
        onSelect={arrangement.selectClip}
        selected={arrangement.selectedIds.includes(clip.id)}
        swatch={arrangement.mixOf(track).color}
        trackName={track.name}
      />
    ))}
</TimelineLane>;
```

While a clip is dragged with Alt, `arrangement.copying` lists the originals: draw an `ArrangedClipGhost` for each, where it stays. `lift` and `onLift` move the rest of a selection up or down with the clip being dragged across tracks.

With snapping on, a dragged clip or loop also lands on the edges of the other clips, the locators and the loop, through `snapPoints`. Where two clips meet, the fade-out of one and the fade-in of the next move together, as a crossfade, and a dashed line shows how far a fade being dragged can go.

## Tracks

A track's header is `TimelineTrackControls`, from the timeline: its name, gain, pan, mute, solo, color, fold and menu. `useTrackReorder` (`npx shadcn@latest add @audio-ui/use-track-reorder`) moves tracks: give each track a grip, and drag the grip, or press Up or Down on it.

```tsx
import { useTrackReorder } from "@/hooks/use-track-reorder";

const reorder = useTrackReorder({
  onMove: arrangement.moveTrack, // (id, index)
  order: tracks.map((track) => track.id),
});

<button aria-label={`Move ${track.name}`} type="button" {...reorder.gripProps(track.id)}>
  ⋮⋮
</button>;
```

The grip sits in its track's row, inside the `Timeline`. While a track is dragged, `draggingId` is the track and `drop` (`{ id, edge }`) is the edge of the row it would land on, to draw a line there; `ghost` is the box of its header, and `ghostRef` goes on the copy you draw under the pointer, fixed, which the drag moves without a render. The view scrolls when the pointer nears its top or bottom edge.

## Locators and time signatures

`TimelineLocator` and `TimelineSignature` (`npx shadcn@latest add @audio-ui/timeline-markers`) go in a `TimelineMark` on a lane of their own. A locator is a named point: press it to put the cursor there, double-click it or press Enter to rename it, Delete to take it away. The block's M adds one at the cursor, and Alt with the arrows jumps from one to the next.

```tsx
import { TimelineLocator, TimelineSignature } from "@/components/audio-ui/timeline-markers";

<TimelineLane>
  {locators.map((locator) => (
    <TimelineMark key={locator.at} value={locator.at}>
      <TimelineLocator
        locator={locator}
        onJump={seek}
        onRemove={removeLocator}
        onRename={renameLocator}
      />
    </TimelineMark>
  ))}
</TimelineLane>;
```

The Signature strip holds time-signature changes: its + adds one at the cursor, in the signature already there, ready to be typed (such as 7/8). From a change on, bars are counted in its signature: the ruler's numbers, the hatched partial bar a change cuts short, and the position in the transport all follow, through `barsOf` and `barsBeatsWith` from `@audio-ui/core/time`. On a change, Left and Right move it by a grid step, Enter edits it and Delete takes it away. The grid's bar-long divisions and the tempo stay those of the song's start. Which change is being typed is yours to hold (`editing` and `onEditingChange`), so a change just added opens ready to type. `parseSignature("7/8")` reads what is typed.

## Automation

Press **A**, or the automation button, for automation mode: each track's header gets a chooser of what its lane shows, Volume or Pan, and its lane the envelope of that parameter over its clips (`automation-envelope`, `npx shadcn@latest add @audio-ui/automation-envelope`). Until the track has one, the line stands faint at the mix's value; double-click it to add a point, drag a point to move it on the grid (its level stays free), drag the small handle on a segment to bend it, and double-click a point to take it away. Beside the line, a press reaches the lane and the clips as before. A MIDI track's channel follows its envelopes as it plays, glided ahead on the audio clock. Moving an automated gain or pan by hand overrides it: the envelope greys, the mix plays, and **Re-enable automation** in the toolbar (or drawing on the envelope) brings it back. In `lib/arrangement`, a track keeps its `automation` per parameter as points across the whole arrangement, `x` from 0 to 1, so a tempo change keeps them in place; `automationAt` reads one, `volumeGain` and `panPosition` turn its values into a gain and a pan, and `mixAt` gives what a track plays at a point of the song.

`TrackAutomationChooser` and `TrackAutomation` (`npx shadcn@latest add @audio-ui/track-automation`) are those two parts: the chooser goes in the track's header (`TimelineTrackControls` takes it as children), the envelope in its lane. `AUTOMATED_PARAMETERS` lists what a track automates, Volume and Pan.

## MIDI clips

A track can also hold MIDI: Bass and Keys are MIDI tracks, and **+ MIDI** under the tracks adds one. A MIDI clip draws its notes across its body and, picked, opens on a piano roll in the clip panel (`midi-clip-panel`, `npx shadcn@latest add @audio-ui/midi-clip-panel`), with the same strip and Clip and Loop boxes (Loop adds Dupl. Loop, which plays the loop's notes again after it) and a Notes box (transpose, invert, reverse, legato, ×2, ÷2, humanize and velocity ramps, on the selected notes or every note): draw notes a grid step at a time, drag the loop brace on its ruler, move, stretch and edit them as in the [piano roll](/docs/components/base/piano-roll), and each edit is one undo step of the arrangement. Double-click a bare MIDI lane for an empty clip a bar long, which loops as it is stretched. Unlike the recordings, MIDI clips are heard: while the arrangement plays, their notes go to a synth, scheduled ahead on the audio clock, on the tracks that are not muted or outside a solo, each MIDI track through a channel of its own that its header's gain and pan set. In `lib/arrangement`, a track's `kind` is `"audio"` or `"midi"`, a MIDI clip keeps its `notes` in its source's time, and `playedNotes` gives where they play through the clip's offset and loop.

`useMidiPlayback` (`npx shadcn@latest add @audio-ui/use-midi-playback`) plays them. Give it the clips, the tracks, the arrangement's clock and whether it plays; `audible` says whether a track is heard and `levelsAt` gives a track's gain and pan at a time, from its mix or its automation. It returns `onNoteOn` and `onNoteOff` for a MIDI clip's editor, which sound what it auditions. Its voices and its bus are the Audio engine's [Synthesis](/docs/engine/synthesis) and [Output](/docs/engine/output); with an engine of your own, leave the hook out and play the clips from `playedNotes`.

## The clip panel

Under the tracks, the clip panel (`clip-panel`, `npx shadcn@latest add @audio-ui/clip-panel`) shows the clip you pick, as a detail view follows the selection; its × hides it until the next pick. Laid out as a DAW's clip view (`clip-view` holds the frame), a strip along its top carries the caret that rolls the panel up to that strip, the clip's switch, its name (type to rename it) and a word on it; under it, its properties sit in boxes beside the editor: Clip (where it starts and ends in its source), Loop (the switch, where the loop sits and how long it is, ÷2 and ×2) and Sample (Warp, Reverse, Crop, Gain and Transpose), all in bars.beats.16ths you can type or step with the arrow keys. Loop and Sample fold to a strip from their heading and give their width to the editor; what is rolled up or folded stays so from clip to clip, and the panel stays at the bottom of the block as the tracks scroll. Its source has a timeline of its own: an overview bar to zoom and scroll with, a ruler in bars, the stretch the clip plays to drag at both ends, or with Loop on the stretch that repeats (a looped clip can then be stretched past its source on the timeline), and the waveform under them. With Warp on, the source is stretched between markers that pin a time in it to a time on the clip: drag a marker, or press its arrows, to stretch what is on either side; double-click the waveform to add one, Delete to take one away, and ×2 or ÷2 to play the whole of it slower or faster. It plays by speed alone, so pitch follows (shown as a rate and its semitones), and a warped clip lasts as long as its warp stretches its source on the timeline. The map is `warpedTime`, `unwarpedTime`, `warpRate` and the marker functions in `@audio-ui/core/time`. The block makes no sound, so these are kept on the clip (`gain`, `transpose`, `reversed`, `loop`, `warp`, `sourceFrom` in `lib/arrangement`) and its waveform is a stand-in.

## Keyboard

A drag across the bare lanes selects a stretch of time on the tracks it sweeps, drawn over them; the time commands below work on it. The block's own keys work once a press on a lane has put the focus in it:

<KeyTable title="Keys">
<KeyRow gesture={"`Left` / `Right`"}>Move the cursor by a grid step. `Home` and `End` go to the start and to the end of the last clip.</KeyRow>
<KeyRow gesture={"`Alt` + `Left` / `Right`"}>Jump to the previous or next locator or clip edge, on the picked tracks (on every track when none is picked).</KeyRow>
<KeyRow gesture={"`Up` / `Down`"}>Pick the track above or below.</KeyRow>
<KeyRow gesture={"`+` / `-`"}>Zoom in or out around the cursor.</KeyRow>
<KeyRow gesture={"`Z`, `X`"}>Zoom onto the selected clips (the loop, when none is selected), and step back to the zoom before.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `1`, `2`"}>A finer or a wider grid.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `3`, `4`, `5`"}>Triplets on or off, snapping on or off, and a grid fixed where it is or following the zoom again.</KeyRow>
<KeyRow gesture={"`M`"}>Add a locator at the cursor.</KeyRow>
<KeyRow gesture={"`S`, `Cmd` / `Ctrl` + `E`"}>Split the selected clips at the cursor.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `L`"}>Put the loop around the time selection, or around the selected clips.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `C`, `X`, `V`"}>Copy or cut the time selection (the selected clips when there is none), and paste it at the cursor, from the picked track down.</KeyRow>
<KeyRow gesture={"`Delete`"}>Empty the time selection on its tracks.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `D`, `J`"}>With a time selection: repeat it right after itself, and make one clip per track of it.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `Shift` + `X`, `V`, `D`, `Delete`"}>The same on time itself, on every track: cut it out, paste it in, repeat it, or take it out, moving what comes after.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `I`"}>Open a stretch of silence at the cursor, as long as the time selection (a bar when there is none).</KeyRow>
<KeyRow gesture={"`0`"}>Switch the selected clips off, or back on.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `Alt` + `F`"}>Fade the clips whose edges the time selection covers, in or out; two clips that meet across it crossfade.</KeyRow>
<KeyRow gesture={"`Delete` on a fade handle"}>Take the fade away.</KeyRow>
<KeyRow gesture={"`Cmd` / `Ctrl` + `Alt` + `3`"}>Show or hide the clip panel, which also shows whenever a clip is picked.</KeyRow>
<KeyRow gesture={"On the loop"}>`Left` / `Right` move it by a grid step, `Up` / `Down` by its own length; with `Cmd` / `Ctrl`, `Left` / `Right` shorten or lengthen it and `Up` / `Down` double or halve it.</KeyRow>
</KeyTable>

## Undo and redo

The `undo-history` lib (`npx shadcn@latest add @audio-ui/undo-history`) implements undo and redo as values: every function takes the present and returns the new present, and keeps no state of its own. Keep the history in a ref and the document in state, so two undos in one tick are fed the first one's result instead of reading the same stack twice.

```ts
let history = emptyHistory<Clip[]>();

// A change made in one go: delete, split, duplicate.
history = recordChange(history, clips);

// A drag reports every tick: open a burst on the first, close it on commit,
// and the whole drag is one entry.
history = beginChange(history, clips);
history = settleChange(history);

const step = undo(history, clips); // { history, present } or null
```

A new change clears redo, since the redo branch no longer follows from the present. `HISTORY_LIMIT` bounds the depth. The arrangement block uses it for its undo and redo buttons and for Cmd/Ctrl + Z, with Shift (or Y) to redo.

## API reference

### ArrangedClip

<TypeTable>
<TypeRow name={"clip"} type={"Arranged"}>The clip: its place (`value`), its source and its notes.</TypeRow>
<TypeRow name={"selected"} type={"boolean"}>Whether it is in the selection.</TypeRow>
<TypeRow name={"swatch"} type={"number"}>Its color, an index into `TRACK_COLORS`: its track's.</TypeRow>
<TypeRow name={"trackName"} type={"string"}>Its track's name, for its label.</TypeRow>
<TypeRow name={"describe"} type={"(time) => string"}>A time in words, such as "Bar 3, beat 2".</TypeRow>
<TypeRow name={"onChange, onCommit"} type={"(id, value, details) => void, (id, details) => void"}>The clip moved, trimmed or faded, and the gesture ended.</TypeRow>
<TypeRow name={"onSelect"} type={"(id, additive) => void"}>It was pressed: `additive` with Shift, Cmd or Ctrl, or with `multi`.</TypeRow>
<TypeRow name={"onAction"} type={"(action, id) => void"}>Picked from its menu: `"edit"`, `"duplicate"`, `"split"`, `"toggle"` or `"remove"`.</TypeRow>
<TypeRow name={"onRename, onRemove, onFadeShape"} type={"(id, …) => void"}>Renamed in place, deleted with its key, and a fade's curve picked.</TypeRow>
<TypeRow name={"multi"} type={"boolean"} default={"false"}>Whether a press adds it to the selection, for a touch screen.</TypeRow>
<TypeRow name={"copying, lift, onLift"} type={"boolean, number, (id, offset) => void"}>For group drags: whether it is a copy being dragged, how far it is held up or down, and how far it is dragged.</TypeRow>
</TypeTable>

### useTrackReorder

<TypeTable>
<TypeRow name={"order"} type={"string[]"}>The tracks' ids, top to bottom.</TypeRow>
<TypeRow name={"onMove"} type={"(id, index) => void"}>A track was dropped at `index`.</TypeRow>
<TypeRow name={"gripProps(id)"} type={"props"}>For a track's grip: its pointer and key handlers.</TypeRow>
<TypeRow name={"draggingId, drop"} type={"string | null, { id, edge } | null"}>The track dragged, and where it would land.</TypeRow>
<TypeRow name={"ghost, ghostRef"} type={"{ id, top, left, width, height } | null, Ref"}>The copy to draw under the pointer, and the ref it takes.</TypeRow>
</TypeTable>

### TimelineLocator and TimelineSignature

<TypeTable>
<TypeRow name={"locator"} type={"{ at, name }"}>A locator.</TypeRow>
<TypeRow name={"onJump, onRemove, onRename"} type={"(at) => void, (at) => void, (at, name) => void"}>Pressed, deleted and renamed.</TypeRow>
<TypeRow name={"change"} type={"{ id, at, beatsPerBar, beatUnit }"}>A time-signature change.</TypeRow>
<TypeRow name={"editing, onEditingChange"} type={"boolean, (id | null) => void"}>Whether it is being typed.</TypeRow>
<TypeRow name={"onMove, onSet, onRemove"} type={"(id, to) => void, (id, beats, unit) => void, (id) => void"}>Moved by `step` with the arrows, typed, deleted.</TypeRow>
<TypeRow name={"step"} type={"number"}>How far the arrows move a change: the grid.</TypeRow>
</TypeTable>

### TrackAutomationChooser and TrackAutomation

<TypeTable>
<TypeRow name={"track"} type={"ArrangementTrack"}>The track, with its `automation`.</TypeRow>
<TypeRow name={"parameter"} type={"\"volume\" | \"pan\""}>What the lane shows.</TypeRow>
<TypeRow name={"onParameterChange, onClear"} type={"(trackId, parameter) => void"}>The chooser's: another parameter picked, and its envelope cleared.</TypeRow>
<TypeRow name={"mix"} type={"Mix"}>The track's mix, where a line with no points stands.</TypeRow>
<TypeRow name={"onChange, onCommit"} type={"(trackId, parameter, points) => void, () => void"}>The envelope was edited, and the gesture ended.</TypeRow>
<TypeRow name={"duration, snapStep, describe"} type={"number, number, (time) => string"}>The arrangement's length, the grid a point snaps to in time, and a time in words.</TypeRow>
<TypeRow name={"overridden"} type={"(\"volume\" | \"pan\")[]"}>The parameters moved by hand since, drawn greyed.</TypeRow>
</TypeTable>

### useMidiPlayback

<TypeTable>
<TypeRow name={"clips, tracks"} type={"Arranged[], ArrangementTrack[]"}>The arrangement.</TypeRow>
<TypeRow name={"clock"} type={"Source<number>"}>Where the arrangement plays, in its time.</TypeRow>
<TypeRow name={"playing"} type={"boolean"}>Whether it plays.</TypeRow>
<TypeRow name={"loop, looping"} type={"[start, end], boolean"}>The loop, which notes are scheduled up to.</TypeRow>
<TypeRow name={"audible"} type={"(trackId) => boolean"}>Whether a track is heard: not muted, and in the solo if there is one.</TypeRow>
<TypeRow name={"levelsAt"} type={"(trackId, time) => { gain, pan }"}>A track's gain and pan at a time.</TypeRow>
<TypeRow name={"returns"} type={"{ onNoteOn, onNoteOff }"}>For a MIDI clip's editor.</TypeRow>
</TypeTable>
