# FM synth

> A four-operator FM synth device: four sines at ratios of the pitch, each with its level and envelope, wired by eight algorithms, with feedback, 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 { FmSynthPanel } from "@/components/audio-ui/devices/fm-synth/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type FmSynthNote,
  type FmSynthState,
  fmSynthDevice,
} from "@/components/audio-ui/devices/fm-synth/device";
import {
  createFmSynthEngine,
  type FmSynthEngine,
} from "@/components/audio-ui/devices/fm-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: FmSynthEngine;
  holding: string | null;
  since: number;
}

/**
 * An eighties four-operator FM 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 BlockFmSynth() {
  const [state, setState] = useState<FmSynthState>(() =>
    fmSynthDevice.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 = createFmSynthEngine(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: FmSynthNote, 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: FmSynthNote) => 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">
      <FmSynthPanel
        activeNotes={midi.activeNotes}
        onNoteOff={releaseKey}
        onNoteOn={playKey}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```

A four-operator FM synth. Each operator is a sine at a ratio of the pitch, with its own level and its own envelope. The algorithm wires them: a carrier is heard, and a modulator bends the pitch of the operator under it many times a second, which grows the harmonics. The deeper a modulator goes, the brighter the sound, so a sound can start bright and mellow as it rings, as an electric piano or a bell does. Operator 4 can feed back on itself, from a sine towards a saw. Start from one of its eight presets (E.Piano, Tubular, Bass, Brass, Marimba, Clav, Organ, Glass Pad), 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/fm-synth
```

It installs the device's state, its engine and its panel to `components/audio-ui/devices/fm-synth/`. The demo above, which wires them to a loop, is `@audio-ui/block-fm-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/fm-synth-device @audio-ui/fm-synth-panel @audio-ui/fm-synth-engine
```

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

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

import { noteToFrequency } from "@audio-ui/core/pitch";
import { useCallback, useEffect, useRef, useState } from "react";
import { FmSynthPanel } from "@/components/audio-ui/devices/fm-synth/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type FmSynthNote,
  type FmSynthState,
  fmSynthDevice,
} from "@/components/audio-ui/devices/fm-synth/device";
import {
  createFmSynthEngine,
  type FmSynthEngine,
} from "@/components/audio-ui/devices/fm-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: FmSynthEngine;
  holding: string | null;
  since: number;
}

/**
 * An eighties four-operator FM 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 BlockFmSynth() {
  const [state, setState] = useState<FmSynthState>(() =>
    fmSynthDevice.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 = createFmSynthEngine(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: FmSynthNote, 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: FmSynthNote) => 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">
      <FmSynthPanel
        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 fm 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

The controls read 0 to 10, the ratios in steps of a list and the algorithm by its number, and show the value they set in the unit the sound takes.

| Control | Range | Default | What it does |
| --- | --- | --- | --- |
| Algorithm | 1: 1 heard to 8: 4 heard | 5: 2 heard | How the four operators are wired, from 1 (all in a stack) to 8 (all heard). |
| Feedback | 0 to 10 | 0 | How far operator 4 feeds back on itself, from a sine towards a saw. |
| Attack | 2 ms to 1.00 s | 2 ms | How long every operator takes to rise. |
| Release | 20 ms to 6.00 s | 111 ms | How long every operator takes to fade once let go. |
| Vibrato | 0 ct to 40 ct | 0 ct | How far the vibrato moves the pitch. |
| Operator 1 ratio | ×0.5 to ×14 | ×1 | Operator 1's ratio of the pitch, from half to fourteen times. |
| Operator 1 level | 0 to 10 | 8 | Operator 1's level: a carrier's loudness, a modulator's depth. |
| Operator 1 decay | 30 ms to 9.00 s | 1.63 s | How long operator 1 takes to fall to its sustain. |
| Operator 1 sustain | 0% to 100% | 0% | The level operator 1 holds at. |
| Operator 2 ratio | ×0.5 to ×14 | ×1 | Operator 2's ratio of the pitch, from half to fourteen times. |
| Operator 2 level | 0 to 10 | 5 | Operator 2's level: a carrier's loudness, a modulator's depth. |
| Operator 2 decay | 30 ms to 9.00 s | 520 ms | How long operator 2 takes to fall to its sustain. |
| Operator 2 sustain | 0% to 100% | 20% | The level operator 2 holds at. |
| Operator 3 ratio | ×0.5 to ×14 | ×1 | Operator 3's ratio of the pitch, from half to fourteen times. |
| Operator 3 level | 0 to 10 | 6 | Operator 3's level: a carrier's loudness, a modulator's depth. |
| Operator 3 decay | 30 ms to 9.00 s | 520 ms | How long operator 3 takes to fall to its sustain. |
| Operator 3 sustain | 0% to 100% | 0% | The level operator 3 holds at. |
| Operator 4 ratio | ×0.5 to ×14 | ×14 | Operator 4's ratio of the pitch, from half to fourteen times. |
| Operator 4 level | 0 to 10 | 4 | Operator 4's level: a carrier's loudness, a modulator's depth. |
| Operator 4 decay | 30 ms to 9.00 s | 166 ms | How long operator 4 takes to fall to its sustain. |
| Operator 4 sustain | 0% to 100% | 0% | The level operator 4 holds at. |
| 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, lays out what every operator shares, then each operator's ratio, level, decay and sustain, and has a keyboard of two octaves that plays the synth. `fmSynthPreset(name)` gives a preset's whole state, `FM_SYNTH_PRESETS` lists them, and `FM_ALGORITHMS` holds each algorithm's carriers and links, to draw its wiring. 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 { FmSynthPanel } from "@/components/audio-ui/devices/fm-synth/panel";
import { fmSynthDevice } from "@/components/audio-ui/devices/fm-synth/device";

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

<FmSynthPanel 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.9 }); // a harder key is louder and brighter
engine.noteOff(); // every operator fades 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 harder key is louder and modulates deeper, and the treble is modulated less, so its harmonics never fold back into noise. Let go, every operator falls back over the release from where it had got to. A note while one is held cuts it and strikes anew. A level, a ratio, the feedback and the vibrato move a held note; the algorithm and the envelopes take the next one. An agent plays the synth with the `play` tool.
