# Tape keys

> A tape-replay keyboard device: 35 keys, each a strip of tape with one note on it, on three tracks (flutes, strings and a choir), with the tape's speed, wow and flutter and a tone, a panel with its keys, 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 { TapeKeysPanel } from "@/components/audio-ui/devices/tape-keys/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type TapeKeysNote,
  type TapeKeysState,
  tapeKeysDevice,
} from "@/components/audio-ui/devices/tape-keys/device";
import {
  createTapeKeysEngine,
  type TapeKeysEngine,
} from "@/components/audio-ui/devices/tape-keys/engine";
import { webAudio } from "@/lib/web-audio";

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

/**
 * A tape-replay keyboard 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 key's tape; eight of them play chords. Play the
 * keyboard, or connect a MIDI keyboard and play chords on it, from G2 to F5.
 */
export function BlockTapeKeys() {
  const [state, setState] = useState<TapeKeysState>(() =>
    tapeKeysDevice.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 = createTapeKeysEngine(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: TapeKeysNote, 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: TapeKeysNote) => noteOn(note, PANEL_NOTE),
    [noteOn]
  );
  const releaseKey = useCallback(() => noteOff(PANEL_NOTE), [noteOff]);
  const midi = useMidiInput({
    onNoteOff: (pitch: number) => noteOff(`midi-${pitch}`),
    onNoteOn: (pitch: number) =>
      noteOn({ frequency: noteToFrequency(pitch) }, `midi-${pitch}`),
  });

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

A tape-replay keyboard. Under each of its 35 keys, G2 to F5, lies a strip of tape with one note recorded on it, on three tracks: flutes, strings and a choir; the track switch chooses the one the heads read. Press a key and its tape runs past the head; hold it for eight seconds and the tape runs out, and the note stops however long the key stays down. Each key's strip is a little off in pitch and level from its neighbours, the tape hisses while it runs, the capstan's wow and flutter bend the pitch, and a tone control darkens the lot. The keys are not touch-sensitive: the tape plays at the level it was recorded. 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/tape-keys
```

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

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

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

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

import { noteToFrequency } from "@audio-ui/core/pitch";
import { useCallback, useEffect, useRef, useState } from "react";
import { TapeKeysPanel } from "@/components/audio-ui/devices/tape-keys/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useMidiInput } from "@/hooks/use-midi-input";
import {
  type TapeKeysNote,
  type TapeKeysState,
  tapeKeysDevice,
} from "@/components/audio-ui/devices/tape-keys/device";
import {
  createTapeKeysEngine,
  type TapeKeysEngine,
} from "@/components/audio-ui/devices/tape-keys/engine";
import { webAudio } from "@/lib/web-audio";

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

/**
 * A tape-replay keyboard 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 key's tape; eight of them play chords. Play the
 * keyboard, or connect a MIDI keyboard and play chords on it, from G2 to F5.
 */
export function BlockTapeKeys() {
  const [state, setState] = useState<TapeKeysState>(() =>
    tapeKeysDevice.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 = createTapeKeysEngine(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: TapeKeysNote, 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: TapeKeysNote) => noteOn(note, PANEL_NOTE),
    [noteOn]
  );
  const releaseKey = useCallback(() => noteOff(PANEL_NOTE), [noteOff]);
  const midi = useMidiInput({
    onNoteOff: (pitch: number) => noteOff(`midi-${pitch}`),
    onNoteOn: (pitch: number) =>
      noteOn({ frequency: noteToFrequency(pitch) }, `midi-${pitch}`),
  });

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <TapeKeysPanel
        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 tape keys 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 number 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 |
| --- | --- | --- | --- |
| Track | Flutes, Strings, Choir | Strings | The track the heads read: flutes, strings or a choir. It takes the next key. |
| Speed | -200 ct to +200 ct | 0 ct | How fast the tape runs, up to a whole tone slower or faster. |
| Wow and flutter | 0 to 10 | 4 | How far the capstan's slow wow and quick flutter bend the pitch. |
| Tone | 1.2 kHz to 14.4 kHz | 5.3 kHz | Where the tone control darkens the sound, from 1.2 kHz to 14 kHz. |
| Volume | -∞ dB to -3 dB | -14.1 dB | The level of the output. |

## Panel

The panel switches the track in its title bar, lays out the tape's speed, wow and tone and the volume, and has the 35 keys that play the tapes. 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 { TapeKeysPanel } from "@/components/audio-ui/devices/tape-keys/panel";
import { tapeKeysDevice } from "@/components/audio-ui/devices/tape-keys/device";

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

<TapeKeysPanel 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 }); // C4's tape runs
engine.noteOff(); // the pads let go of it

// 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 key's tape, through its own tape path. 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 plays the key nearest its pitch, and a pitch outside the 35 keys plays nothing. A key's tape runs for eight seconds at most; let go before, and the pads let go of the tape at once. A note while one is held cuts it and runs anew, and a `noteOff` whose `id` is not the held note's lets nothing go. The speed, the wow and the tone move a held key; the track takes the next. An agent plays the keyboard with the `play` tool.
