# Poly synth

> An eighties polyphonic synth device: a saw, a pulse, a sub oscillator and noise into a resonant filter, an envelope, a vibrato and a chorus, with eight presets, a panel with a keyboard, a Web Audio engine and tools for agents.

```tsx
"use client";

import { noteToFrequency } from "@audio-ui/core/pitch";
import { useCallback, useEffect, useRef, useState } from "react";
import { PolySynthPanel } from "@/components/audio-ui/devices/poly-synth/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type PolySynthNote,
  type PolySynthState,
  polySynthDevice,
} from "@/components/audio-ui/devices/poly-synth/device";
import {
  createPolySynthEngine,
  type PolySynthEngine,
} from "@/components/audio-ui/devices/poly-synth/engine";
import { webAudio } from "@/lib/web-audio";

/** How many notes sound 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: PolySynthEngine;
  holding: string | null;
  since: number;
}

/**
 * An eighties polyphonic synth 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. The engine plays one voice; eight of them play chords. Play the
 * keyboard, or connect a MIDI keyboard and play chords on it.
 */
export function BlockPolySynth() {
  const [state, setState] = useState<PolySynthState>(() =>
    polySynthDevice.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 = createPolySynthEngine(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: PolySynthNote, 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: PolySynthNote) => 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}`),
  });

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <PolySynthPanel
        activeNotes={midi.activeNotes}
        onNoteOff={releaseKey}
        onNoteOn={playKey}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```

An eighties polyphonic synth. Four sources (a saw, a pulse of any width, a sub oscillator an octave down and noise) are mixed into a resonant low-pass filter, which an envelope opens along with the level, and a vibrato and a stereo chorus finish the sound. Start from one of its eight presets (Brass, Strings, Soft Pad, Pluck, Fat Bass, Organ, Sweep, Glass Lead), then shape it. 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/poly-synth
```

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

**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/poly-synth-device @audio-ui/poly-synth-panel @audio-ui/poly-synth-engine
```

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

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

import { noteToFrequency } from "@audio-ui/core/pitch";
import { useCallback, useEffect, useRef, useState } from "react";
import { PolySynthPanel } from "@/components/audio-ui/devices/poly-synth/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type PolySynthNote,
  type PolySynthState,
  polySynthDevice,
} from "@/components/audio-ui/devices/poly-synth/device";
import {
  createPolySynthEngine,
  type PolySynthEngine,
} from "@/components/audio-ui/devices/poly-synth/engine";
import { webAudio } from "@/lib/web-audio";

/** How many notes sound 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: PolySynthEngine;
  holding: string | null;
  since: number;
}

/**
 * An eighties polyphonic synth 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. The engine plays one voice; eight of them play chords. Play the
 * keyboard, or connect a MIDI keyboard and play chords on it.
 */
export function BlockPolySynth() {
  const [state, setState] = useState<PolySynthState>(() =>
    polySynthDevice.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 = createPolySynthEngine(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: PolySynthNote, 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: PolySynthNote) => 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}`),
  });

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <PolySynthPanel
        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 poly synth 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

Every control reads 0 to 10, as a panel's sliders do, and shows the value it sets in the unit the sound takes.

| Control | Range | Default | What it does |
| --- | --- | --- | --- |
| Saw | 0 to 10 | 7 | The level of the saw. |
| Pulse | 0 to 10 | 4 | The level of the pulse. |
| Pulse width | 50% to 5% | 37% | The pulse's width: a square at 0, thin at 10. |
| Sub | 0 to 10 | 3 | The level of the square an octave down. |
| Noise | 0 to 10 | 0 | The level of the white noise. |
| Cutoff | 40 Hz to 12 kHz | 1.2 kHz | Where the low-pass filter closes, from 40 Hz to 12 kHz. |
| Resonance | 0 dB to 14 dB | 2.8 dB | How much the filter rings at its cutoff. |
| Envelope amount | 0 st to 48 st | 19 st | How far the envelope opens the filter, up to four octaves. |
| Key follow | 0% to 100% | 50% | How much the cutoff follows the pitch: at 10, an octave up opens it an octave. |
| Attack | 3 ms to 1.80 s | 6 ms | How long a note takes to rise. |
| Decay | 10 ms to 3.00 s | 173 ms | How long it takes to fall to the sustain. |
| Sustain | 0% to 100% | 70% | The level a held note stays at. |
| Release | 10 ms to 5.00 s | 65 ms | How long a note takes to fade once let go. |
| Vibrato rate | 0.2 Hz to 20 Hz | 1.3 Hz | The vibrato's speed. |
| Vibrato depth | 0 ct to 80 ct | 0 ct | How far the vibrato moves the pitch. |
| Chorus | Off, I, II | I | The stereo chorus: off, I or the wider II. |
| Volume | -∞ dB to -3 dB | -14.1 dB | The level of the output. |

## Panel

The panel loads a preset from the menu in its title bar, beside the chorus switch, lays the other controls out by what they do (oscillators, filter, envelope, vibrato, output) and has a keyboard of two octaves that plays the synth. `polySynthPreset(name)` gives a preset's whole state, and `POLY_SYNTH_PRESETS` lists them. With `activeNotes`, the notes held now, such as on a MIDI keyboard, light up their keys. The panel sends notes out and does not make the sound, so it plays through any engine.

```tsx
import { PolySynthPanel } from "@/components/audio-ui/devices/poly-synth/panel";
import { polySynthDevice } from "@/components/audio-ui/devices/poly-synth/device";

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

<PolySynthPanel 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: 261.63, velocity: 0.8 }); // a note now, or the held one glides to it
engine.noteOff(); // lets it go, over the release

// Scheduled ahead on the audio clock, by a host that plays notes from a song:
engine.noteOn({ frequency: 440 }, { at: time, id: "a4" });
engine.noteOff({ at: time + length, id: "a4" });
```

An engine plays one voice. For chords, make as many as the notes you hold and give each note its own: the [arrangement](/docs/components/base/arrangement)'s engine does it for a MIDI track, and the block above keeps eight. A note rises over its attack, then settles at its sustain; the filter opens by the envelope amount on the same curves. Let go, both fall back over the release from where they had got to, so a short note never clicks. A `noteOff` whose `id` is not the held note's lets nothing go. Every control moves with a ramp, and a held note follows the ones that shape it. An agent plays the synth with the `play` tool.
