# Process Menu

> The operations on a sound's samples as menu items: Reverse, Repeat, Add silence, Normalize, Pan, Dither, Filter, Stretch, Pitch, Speed and Repair, each handed to you as data to run.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/process-menu
```

**Manual**

**1. Install the following dependencies:**

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

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

```tsx title="components/audio-ui/process-menu.tsx"
import type {
  DitherMethod,
  ShiftMethod,
  StretchMethod,
} from "@audio-ui/engine/files";
import type { Repair } from "@audio-ui/engine/repair";
import { type ReactNode, useCallback } from "react";
import {
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
import {
  REPAIR_NAMES,
  type SoundOperation,
} from "@/lib/sound-operations";

/** One operation as the menu offers it: what it says, with a note at its end. */
interface OperationEntry {
  label: string;
  note?: string;
  operation: SoundOperation;
}

/** The ways of one method, its operations in a submenu of their own. */
interface MethodEntry {
  items: readonly OperationEntry[];
  label: string;
  note: string;
}

const REPEAT_ITEMS = [2, 3, 4, 8].map(
  (times): OperationEntry => ({
    label: `${times} times`,
    operation: { times, type: "repeat" },
  })
);

const PAD_ITEMS = [0.5, 1, 2].map(
  (seconds): OperationEntry => ({
    label: `${seconds} s at the end`,
    operation: { seconds, type: "pad" },
  })
);

const NORMALIZE_ITEMS: readonly OperationEntry[] = [
  {
    label: "Peak to -1 dBFS",
    note: "Safe ceiling",
    operation: { target: { peak: -1 }, type: "normalize" },
  },
  {
    label: "-23 LUFS",
    note: "Broadcast, EBU R128",
    operation: { target: { lufs: -23 }, type: "normalize" },
  },
  {
    label: "-16 LUFS",
    note: "Podcasts",
    operation: { target: { lufs: -16 }, type: "normalize" },
  },
  {
    label: "-14 LUFS",
    note: "Streaming",
    operation: { target: { lufs: -14 }, type: "normalize" },
  },
];

const PAN_ITEMS: readonly OperationEntry[] = [
  { label: "Left", operation: { pan: -1, type: "pan" } },
  { label: "Half left", operation: { pan: -0.5, type: "pan" } },
  { label: "Half right", operation: { pan: 0.5, type: "pan" } },
  { label: "Right", operation: { pan: 1, type: "pan" } },
];

const DITHERS: readonly MethodEntry[] = (
  [
    { label: "Triangular", method: "tpdf", note: "Steady noise" },
    { label: "Noise-shaped", method: "shaped", note: "Noise moved up" },
    { label: "Error feedback", method: "feedback", note: "No added noise" },
  ] as const satisfies readonly {
    label: string;
    method: DitherMethod;
    note: string;
  }[]
).map(({ label, method, note }) => ({
  items: [16, 8].map(
    (bits): OperationEntry => ({
      label: `${bits} bits`,
      operation: { bits, method, type: "dither" },
    })
  ),
  label,
  note,
}));

const FILTER_ITEMS: readonly OperationEntry[] = [
  {
    label: "Remove rumble",
    note: "High-pass 40 Hz",
    operation: { filter: "highpass", frequency: 40, type: "filter" },
  },
  {
    label: "Thin the lows",
    note: "High-pass 80 Hz",
    operation: { filter: "highpass", frequency: 80, type: "filter" },
  },
  {
    label: "Soften the highs",
    note: "Low-pass 8 kHz",
    operation: { filter: "lowpass", frequency: 8000, type: "filter" },
  },
  {
    label: "Telephone",
    note: "Low-pass 3 kHz",
    operation: { filter: "lowpass", frequency: 3000, type: "filter" },
  },
];

const STRETCHES: readonly MethodEntry[] = (
  [
    { label: "Transient-aware", method: "transient", note: "Music with drums" },
    { label: "Phase vocoder", method: "pvocLock", note: "General music" },
    { label: "Overlap-add", method: "wsola", note: "Speech, shorter" },
    {
      label: "Vocoder and overlap-add",
      method: "pvsola",
      note: "Speech, longer",
    },
    { label: "Hybrid", method: "hybrid", note: "Full mixes" },
    { label: "PaulStretch", method: "paulstretch", note: "Ambient, extreme" },
  ] as const satisfies readonly {
    label: string;
    method: StretchMethod;
    note: string;
  }[]
).map(({ label, method, note }) => ({
  items: [0.5, 0.75, 1.25, 1.5, 2].map(
    (factor): OperationEntry => ({
      label: `${Math.round(factor * 100)} % as long`,
      operation: { factor, method, type: "stretch" },
    })
  ),
  label,
  note,
}));

const SHIFTS: readonly MethodEntry[] = (
  [
    { label: "Automatic", method: "auto", note: "Chosen from the sound" },
    { label: "Transient-aware", method: "transient", note: "Music with drums" },
    { label: "Phase-locked", method: "phaseLock", note: "General music" },
    {
      label: "Formant-preserving",
      method: "formant",
      note: "Voice, keeps its character",
    },
  ] as const satisfies readonly {
    label: string;
    method: ShiftMethod;
    note: string;
  }[]
).map(({ label, method, note }) => ({
  items: [12, 7, 5, 2, -2, -5, -7, -12].map(
    (semitones): OperationEntry => ({
      label: `${semitones > 0 ? "+" : ""}${semitones} semitones`,
      operation: { method, semitones, type: "shift" },
    })
  ),
  label,
  note,
}));

const SPEED_ITEMS = [0.5, 0.75, 1.25, 1.5, 2].map(
  (speed): OperationEntry => ({
    label: `×${speed}`,
    operation: { speed, type: "speed" },
  })
);

const REPAIR_ITEMS = (
  [
    { note: "Finds the defect", repair: "clean" },
    { note: "Vinyl, mouth, splices", repair: "clicks" },
    { note: "Shellac, dense ticks", repair: "crackle" },
    { note: "Europe, Africa, Asia", repair: "hum50" },
    { note: "Americas", repair: "hum60" },
    { note: "Steady hiss", repair: "noise" },
    { note: "Changing noise", repair: "speechNoise" },
    { note: "Rebuilds cut peaks", repair: "clipping" },
    { note: "Low rumble", repair: "wind" },
    { note: "P and b bursts", repair: "plosives" },
    { note: "Between phrases", repair: "breaths" },
    { note: "A room's tail", repair: "reverb" },
  ] as const satisfies readonly { note: string; repair: Repair }[]
).map(
  ({ note, repair }): OperationEntry => ({
    label: REPAIR_NAMES[repair],
    note,
    operation: { repair, type: "repair" },
  })
);

const REVERSE: OperationEntry = {
  label: "Reverse",
  operation: { type: "reverse" },
};

/** What a menu row says at its end, in a lighter tone. */
function Note({ children }: { children: ReactNode }) {
  return (
    <span className="ms-auto text-muted-foreground text-xs">{children}</span>
  );
}

/** One operation, as an item. */
function OperationItem({
  disabled,
  entry,
  onProcess,
}: {
  disabled?: boolean;
  entry: OperationEntry;
  onProcess: (operation: SoundOperation) => void;
}) {
  const run = useCallback(
    () => onProcess(entry.operation),
    [entry.operation, onProcess]
  );
  return (
    <DropdownMenuItem disabled={disabled} onClick={run}>
      {entry.label}
      {entry.note ? <Note>{entry.note}</Note> : null}
    </DropdownMenuItem>
  );
}

/** A submenu named `label` of `items`. */
function OperationSub({
  className,
  disabled,
  items,
  label,
  note,
  onProcess,
}: {
  className: string;
  disabled?: boolean;
  items: readonly OperationEntry[];
  label: string;
  note?: string;
  onProcess: (operation: SoundOperation) => void;
}) {
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger disabled={disabled}>
        {label}
        {note ? <Note>{note}</Note> : null}
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent className={className}>
        {items.map((entry) => (
          <OperationItem
            entry={entry}
            key={entry.label}
            onProcess={onProcess}
          />
        ))}
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

/** A submenu named `label` of methods, each a submenu of its operations. */
function MethodSub({
  disabled,
  label,
  methods,
  onProcess,
  width,
}: {
  disabled?: boolean;
  label: string;
  methods: readonly MethodEntry[];
  onProcess: (operation: SoundOperation) => void;
  width: string;
}) {
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger disabled={disabled}>
        {label}
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent className={width}>
        {methods.map((method) => (
          <OperationSub
            className="w-48"
            items={method.items}
            key={method.label}
            label={method.label}
            note={method.note}
            onProcess={onProcess}
          />
        ))}
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

/**
 * The Process menu's items, in four groups: Structure (Reverse, Repeat, Add
 * silence), Level and tone (Normalize, Pan, Dither, Filter), Time and pitch
 * (Stretch, Pitch, Speed, each by the method picked) and Repair. Place them in
 * a dropdown menu's content or a submenu's; `children` go first in Structure,
 * for the edits of your own.
 */
export function ProcessMenuItems({
  children,
  disabled,
  onProcess,
}: {
  /** Items of your own, at the top of the Structure group. */
  children?: ReactNode;
  /** Whether there is nothing to process: every item is greyed out. */
  disabled?: boolean;
  /** An operation was picked: run it with `applySoundOperation`. */
  onProcess: (operation: SoundOperation) => void;
}) {
  return (
    <>
      <DropdownMenuGroup>
        <DropdownMenuLabel>Structure</DropdownMenuLabel>
        {children}
        <OperationItem
          disabled={disabled}
          entry={REVERSE}
          onProcess={onProcess}
        />
        <OperationSub
          className="w-48"
          disabled={disabled}
          items={REPEAT_ITEMS}
          label="Repeat"
          onProcess={onProcess}
        />
        <OperationSub
          className="w-48"
          disabled={disabled}
          items={PAD_ITEMS}
          label="Add silence"
          onProcess={onProcess}
        />
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuGroup>
        <DropdownMenuLabel>Level and tone</DropdownMenuLabel>
        <OperationSub
          className="w-64"
          disabled={disabled}
          items={NORMALIZE_ITEMS}
          label="Normalize"
          onProcess={onProcess}
        />
        <OperationSub
          className="w-48"
          disabled={disabled}
          items={PAN_ITEMS}
          label="Pan"
          onProcess={onProcess}
        />
        <MethodSub
          disabled={disabled}
          label="Dither"
          methods={DITHERS}
          onProcess={onProcess}
          width="w-64"
        />
        <OperationSub
          className="w-64"
          disabled={disabled}
          items={FILTER_ITEMS}
          label="Filter"
          onProcess={onProcess}
        />
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuGroup>
        <DropdownMenuLabel>Time and pitch</DropdownMenuLabel>
        <MethodSub
          disabled={disabled}
          label="Stretch"
          methods={STRETCHES}
          onProcess={onProcess}
          width="w-72"
        />
        <MethodSub
          disabled={disabled}
          label="Pitch"
          methods={SHIFTS}
          onProcess={onProcess}
          width="w-72"
        />
        <OperationSub
          className="w-48"
          disabled={disabled}
          items={SPEED_ITEMS}
          label="Speed"
          onProcess={onProcess}
        />
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuGroup>
        <DropdownMenuLabel>Repair</DropdownMenuLabel>
        <OperationSub
          className="w-80"
          disabled={disabled}
          items={REPAIR_ITEMS}
          label="Remove a defect"
          onProcess={onProcess}
        />
      </DropdownMenuGroup>
    </>
  );
}
```

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

**Agent**

Give your agent the skill, and it picks the component, installs it and wires it to your audio:

```bash
npx skills add ouestlabs/audio-ui
```

Then ask it:

```text
Add the process menu from audio/ui to my project and wire it to my audio code.
```

To let the agent read the docs live, connect the [MCP server](/docs/mcp). More in [Agent Skill](/docs/agents).

## Usage

`ProcessMenuItems` lists the operations that change a sound's samples, in four groups: Structure (Reverse, Repeat, Add silence), Level and tone (Normalize, Pan, Dither, Filter), Time and pitch (Stretch, Pitch, Speed, each by the method picked) and Repair. Place them in a dropdown menu's content, or a submenu's, with a trigger of your own. The audio editor's Process menu and the clip panel's are made of them.

```tsx
import { ProcessMenuItems } from "@/components/audio-ui/process-menu";
import { applySoundOperation, soundOperationName } from "@/lib/sound-operations";

<DropdownMenu>
  <DropdownMenuTrigger render={<Button aria-label="Process" size="icon" variant="outline" />}>
    <WandSparklesIcon />
  </DropdownMenuTrigger>
  <DropdownMenuContent align="start" className="w-64">
    <ProcessMenuItems
      disabled={!sound}
      onProcess={(operation) => {
        setSound(applySoundOperation(sound, operation));
        setStatus(soundOperationName(operation)); // "Repeat ×2", "Pitch -5 st"
      }}
    />
  </DropdownMenuContent>
</DropdownMenu>;
```

`onProcess` gets the operation picked, as data (`{ type: "repeat", times: 2 }`), so the menu does no audio work itself: [`sound-operations`](/docs/lib/sound-operations) runs it on plain channels and names it as a step of the history, and you decide what it runs on and when. The audio editor bakes it on the sound as edited with [`useBake`](/docs/hooks/use-bake); the [clip panel](/docs/components/base/arrangement#the-clip-panel) offers it in the bar over the clip's sound and in its right-click menu, and hands it to its host with the stretch selected, which processes the clip's source into a recording of its own.

### Items of your own

`children` go first in the Structure group, beside the operations. The audio editor puts its edits that clean a sound up there:

```tsx
<ProcessMenuItems disabled={!sound} onProcess={baking.process}>
  <DropdownMenuItem onClick={edits.trimSilence}>Trim silence</DropdownMenuItem>
  <DropdownMenuSub>
    <DropdownMenuSubTrigger>Shrink pauses</DropdownMenuSubTrigger>
    <DropdownMenuSubContent>…</DropdownMenuSubContent>
  </DropdownMenuSub>
</ProcessMenuItems>
```

## API reference

### ProcessMenuItems

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onProcess` | `(operation: SoundOperation) => void` |  | An operation was picked: run it with `applySoundOperation`. |
| `disabled` | `boolean` |  | Whether there is nothing to process: every item is greyed out. |
| `children` | `ReactNode` |  | Items of your own, at the top of the Structure group. |
