# Sampler

> A slice sampler device: a sample cut into sixteen slices, a pad or a key each, through a filter, transposed, struck or gated, with a panel of pads, a Web Audio engine and tools for agents.

```tsx
"use client";

import { noteToFrequency } from "@audio-ui/core/pitch";
import {
  type DragEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";
import { SamplerPanel } from "@/components/audio-ui/devices/sampler/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type SamplerNote,
  type SamplerState,
  samplerDevice,
} from "@/components/audio-ui/devices/sampler/device";
import {
  createSamplerEngine,
  type SamplerEngine,
} from "@/components/audio-ui/devices/sampler/engine";
import { webAudio } from "@/lib/web-audio";

/** How many slices play at once: a ninth takes the voice held longest. */
const VOICES = 8;
/** The note the panel's keyboard holds: one at a time, under its own name. */
const PANEL_NOTE = "panel";

interface Voice {
  engine: SamplerEngine;
  holding: string | null;
  since: number;
}

/**
 * A slice sampler you can plug in: its state, a panel that draws and plays it,
 * and a Web Audio engine that makes the sound, each its own file. Strike the
 * pads, or play a MIDI keyboard from C2: each key plays a slice. Drop an audio
 * file on it and it plays that instead of its own sample.
 */
export function BlockSampler() {
  const [state, setState] = useState<SamplerState>(() =>
    samplerDevice.defaults()
  );
  const master = useMasterBus(1);
  const [voices, setVoices] = useState<Voice[] | null>(null);
  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && master)) {
      return;
    }
    const made = Array.from({ length: VOICES }, () => {
      const engine = createSamplerEngine(ctx, stateRef.current);
      engine.output.connect(master);
      return { engine, holding: null, since: 0 };
    });
    setVoices(made);
    return () => {
      for (const voice of made) {
        voice.engine.dispose();
      }
      setVoices(null);
    };
  }, [master]);

  // Every voice follows the state, whoever changed it: a control or a call.
  useEffect(() => {
    for (const voice of voices ?? []) {
      voice.engine.update(state);
    }
  }, [state, voices]);

  /** A note on the voice that holds it, else a free one, else the one held longest. */
  const noteOn = useCallback(
    (note: SamplerNote, id: string) => {
      if (!voices) {
        return;
      }
      const voice =
        voices.find((item) => item.holding === id) ??
        voices.find((item) => item.holding === null) ??
        voices.reduce((a, b) => (a.since <= b.since ? a : b));
      if (voice.holding !== null && voice.holding !== id) {
        voice.engine.noteOff();
      }
      voice.holding = id;
      voice.since = performance.now();
      voice.engine.noteOn(note, { id });
    },
    [voices]
  );
  const noteOff = useCallback(
    (id: string) => {
      const voice = voices?.find((item) => item.holding === id);
      if (voice) {
        voice.holding = null;
        voice.engine.noteOff({ id });
      }
    },
    [voices]
  );

  const playKey = useCallback(
    (note: SamplerNote) => noteOn(note, PANEL_NOTE),
    [noteOn]
  );
  const releaseKey = useCallback(() => noteOff(PANEL_NOTE), [noteOff]);
  const midi = useMidiInput({
    onNoteOff: (pitch: number) => noteOff(`midi-${pitch}`),
    onNoteOn: (pitch: number, velocity: number) =>
      noteOn({ frequency: noteToFrequency(pitch), velocity }, `midi-${pitch}`),
  });

  // A file dropped on the sampler is decoded and played in place of its own sample.
  const [file, setFile] = useState<string | null>(null);
  const takeFile = useCallback((event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
  }, []);
  const dropFile = useCallback(
    async (event: DragEvent<HTMLDivElement>) => {
      const [dropped] = Array.from(event.dataTransfer.files);
      const ctx = webAudio.getContext();
      event.preventDefault();
      if (!(dropped && ctx && voices)) {
        return;
      }
      try {
        const buffer = await ctx.decodeAudioData(await dropped.arrayBuffer());
        for (const voice of voices) {
          voice.engine.load(buffer);
        }
        setFile(dropped.name);
      } catch {
        setFile(null);
      }
    },
    [voices]
  );
  // The state naming another sample lets go of the file.
  const { sample } = state;
  useEffect(() => {
    if (sample) {
      setFile(null);
    }
  }, [sample]);

  return (
    <div
      aria-label={file ? `Sampler, playing ${file}` : "Sampler"}
      className="rounded-xl h-full w-full min-w-0"
      onDragOver={takeFile}
      onDrop={dropFile}
      role="group"
    >
      <SamplerPanel
        activeNotes={midi.activeNotes}
        onNoteOff={releaseKey}
        onNoteOn={playKey}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```

A slice sampler. A sample is cut into sixteen equal slices, and each pad, or each key from C2 (note 36), plays one: struck, it plays to its end; with the gate on, it plays while the key is held. A low-pass filter, a pitch and a release shape it. It comes with two samples of its own, a dusty keys loop and a sub bass line, so it sounds on first load and travels with a project; drop an audio file on the block above to play that instead. It is a [device](/docs/devices): a state, a panel, an engine and tools, each its own file.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/sampler
```

It installs the device's state, its engine and its panel to `components/audio-ui/devices/sampler/`. The demo above, which wires them to a loop, is `@audio-ui/block-sampler`.

**Manual**

**1. Install the packages:**

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

**2. Add the pieces you want. All of them, or only the state and the panel if your engine is elsewhere:**

```bash
npx shadcn@latest add @audio-ui/sampler-device @audio-ui/sampler-panel @audio-ui/sampler-engine
```

**3. Copy and paste the demo that wires them, if you want it.**

```tsx title="components/examples/block-sampler.tsx"
"use client";

import { noteToFrequency } from "@audio-ui/core/pitch";
import {
  type DragEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";
import { SamplerPanel } from "@/components/audio-ui/devices/sampler/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type SamplerNote,
  type SamplerState,
  samplerDevice,
} from "@/components/audio-ui/devices/sampler/device";
import {
  createSamplerEngine,
  type SamplerEngine,
} from "@/components/audio-ui/devices/sampler/engine";
import { webAudio } from "@/lib/web-audio";

/** How many slices play at once: a ninth takes the voice held longest. */
const VOICES = 8;
/** The note the panel's keyboard holds: one at a time, under its own name. */
const PANEL_NOTE = "panel";

interface Voice {
  engine: SamplerEngine;
  holding: string | null;
  since: number;
}

/**
 * A slice sampler you can plug in: its state, a panel that draws and plays it,
 * and a Web Audio engine that makes the sound, each its own file. Strike the
 * pads, or play a MIDI keyboard from C2: each key plays a slice. Drop an audio
 * file on it and it plays that instead of its own sample.
 */
export function BlockSampler() {
  const [state, setState] = useState<SamplerState>(() =>
    samplerDevice.defaults()
  );
  const master = useMasterBus(1);
  const [voices, setVoices] = useState<Voice[] | null>(null);
  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && master)) {
      return;
    }
    const made = Array.from({ length: VOICES }, () => {
      const engine = createSamplerEngine(ctx, stateRef.current);
      engine.output.connect(master);
      return { engine, holding: null, since: 0 };
    });
    setVoices(made);
    return () => {
      for (const voice of made) {
        voice.engine.dispose();
      }
      setVoices(null);
    };
  }, [master]);

  // Every voice follows the state, whoever changed it: a control or a call.
  useEffect(() => {
    for (const voice of voices ?? []) {
      voice.engine.update(state);
    }
  }, [state, voices]);

  /** A note on the voice that holds it, else a free one, else the one held longest. */
  const noteOn = useCallback(
    (note: SamplerNote, id: string) => {
      if (!voices) {
        return;
      }
      const voice =
        voices.find((item) => item.holding === id) ??
        voices.find((item) => item.holding === null) ??
        voices.reduce((a, b) => (a.since <= b.since ? a : b));
      if (voice.holding !== null && voice.holding !== id) {
        voice.engine.noteOff();
      }
      voice.holding = id;
      voice.since = performance.now();
      voice.engine.noteOn(note, { id });
    },
    [voices]
  );
  const noteOff = useCallback(
    (id: string) => {
      const voice = voices?.find((item) => item.holding === id);
      if (voice) {
        voice.holding = null;
        voice.engine.noteOff({ id });
      }
    },
    [voices]
  );

  const playKey = useCallback(
    (note: SamplerNote) => noteOn(note, PANEL_NOTE),
    [noteOn]
  );
  const releaseKey = useCallback(() => noteOff(PANEL_NOTE), [noteOff]);
  const midi = useMidiInput({
    onNoteOff: (pitch: number) => noteOff(`midi-${pitch}`),
    onNoteOn: (pitch: number, velocity: number) =>
      noteOn({ frequency: noteToFrequency(pitch), velocity }, `midi-${pitch}`),
  });

  // A file dropped on the sampler is decoded and played in place of its own sample.
  const [file, setFile] = useState<string | null>(null);
  const takeFile = useCallback((event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
  }, []);
  const dropFile = useCallback(
    async (event: DragEvent<HTMLDivElement>) => {
      const [dropped] = Array.from(event.dataTransfer.files);
      const ctx = webAudio.getContext();
      event.preventDefault();
      if (!(dropped && ctx && voices)) {
        return;
      }
      try {
        const buffer = await ctx.decodeAudioData(await dropped.arrayBuffer());
        for (const voice of voices) {
          voice.engine.load(buffer);
        }
        setFile(dropped.name);
      } catch {
        setFile(null);
      }
    },
    [voices]
  );
  // The state naming another sample lets go of the file.
  const { sample } = state;
  useEffect(() => {
    if (sample) {
      setFile(null);
    }
  }, [sample]);

  return (
    <div
      aria-label={file ? `Sampler, playing ${file}` : "Sampler"}
      className="rounded-xl h-full w-full min-w-0"
      onDragOver={takeFile}
      onDrop={dropFile}
      role="group"
    >
      <SamplerPanel
        activeNotes={midi.activeNotes}
        onNoteOff={releaseKey}
        onNoteOn={playKey}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```

**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 sampler 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).

## Controls

| Control | Range | Default | What it does |
| --- | --- | --- | --- |
| Sample | Dusty keys, Sub line | Dusty keys | The sample the slices are cut from. |
| Pitch | -12 st to 12 st | 0 st | How far the slices are transposed, in semitones: up plays them faster and shorter. |
| Filter | 200 Hz to 18 kHz | 18 kHz | Where the low-pass filter closes, from 200 Hz to 18 kHz. |
| Release | 10 ms to 1.50 s | 45 ms | How long a gated slice takes to close once let go, and how long every slice fades at its end. |
| Gate | On or off | Off | Off, a slice plays to its end however short the press; on, it plays while the key is held. |
| Volume | 0% to 100% | 49% | The level of the output. |

## Panel

The panel has the sample in its title bar, its controls, and sixteen pads in two rows, one a slice, that play it: a pad struck sends the note of its slice, as hard as it was struck. `sliceOf(pitch)` is the slice a pitch plays, and `SAMPLER_FIRST_NOTE` the note of the first. With `activeNotes`, the notes held now, such as on a MIDI keyboard, light up the pads of their slices. The panel sends notes out and does not make the sound, so it plays through any engine.

```tsx
import { SamplerPanel } from "@/components/audio-ui/devices/sampler/panel";
import { samplerDevice } from "@/components/audio-ui/devices/sampler/device";

const [state, setState] = useState(() => samplerDevice.defaults());

<SamplerPanel onValueChange={setState} value={state} />;
```

## Engine

Create it, connect it and update it as for any device: see [The engine](/docs/devices#the-engine).

```ts
engine.noteOn({ frequency: 65.41, velocity: 1 }); // C2: the first slice
engine.noteOff(); // with the gate on, it closes over the release

// A file of your own in place of the sample the state names:
engine.load(await context.decodeAudioData(await file.arrayBuffer()));
```

An engine plays one slice at a time; a host plays several at once with several engines, one note each, as the [arrangement](/docs/components/base/arrangement)'s engine does for a MIDI track. The samples it comes with are rendered once and shared by every engine, and `ready` resolves once the state's sample is there: a note before plays nothing. A slice rises in three milliseconds and fades at its end, so it never clicks. A note while one plays cuts it and plays anew. `load(buffer)` plays a file in place of the state's sample until the state names another; `load(null)` goes back to it. An agent plays the sampler with the `play` tool.
