---
title: "Audio"
description: "Play audio, record files and analyse microphone input."
---

Use `@ink/audio` to play audio and manage queues.

| Import | Use |
| --- | --- |
| `@ink/audio` | Playback and native media controls. |
| `@ink/audio/microphone` | Sound levels and pitch detection. |
| `@ink/audio/recording` | Record audio files. |

## Permissions

| Permission | Required for |
| --- | --- |
| `microphone` | Recording, sound levels and pitch detection. |

Playback does not require microphone access.

```ts
import { lightos } from "@ink/lightos";

const permission = await lightos.requestPermission("microphone");
```

Use `lightos.getPermission("microphone")` to check without a prompt. See [Request a permission](/permissions-guide) for the returned statuses.

Granting permission does not start recording or analysis. Wait for the hook’s `ready` state, then call `start()`.

## Playback

This screen controls an existing queue. Call `setQueue` with your tracks first; `usePlayer` does not load them automatically.

<div className="pattern-example">
<div>

```tsx
import { Button, Screen, Text, useAction } from "ink";
import { usePlayer } from "@ink/audio";

export function NowPlaying() {
  const player = usePlayer({ session: "main", mode: "detached" });
  const toggle = useAction(() => player.toggle());
  return (
    <Screen title="Now playing">
      <Text>{player.state.current?.title ?? "Nothing playing"}</Text>
      <Button onPress={() => toggle.run()} disabled={!player.state.ready}>
        {player.state.playWhenReady ? "Pause" : "Play"}
      </Button>
      {toggle.status === "error" && <Text>{toggle.error.message}</Text>}
    </Screen>
  );
}
```

</div>
<div className="pattern-preview">
**Paused**

<img src="/images/patterns/audio-paused.png" alt="A loaded track with a Play button." width="1080" height="1240" />

**Playing**

<img src="/images/patterns/audio-playing.png" alt="The same track with a Pause button." width="1080" height="1240" />

</div>
</div>

Wait for `state.ready` before sending commands or reading a restored queue. Commands sent before readiness reject.

### Keep playing after leaving a screen

Inside your playback component:

```ts
const player = usePlayer({ session: "main", mode: "detached" });
```

Detached playback continues after leaving the screen.

| Option | Behaviour |
| --- | --- |
| `mode: "attached"` (default) | Stops when the last screen using the session releases it. |
| `mode: "detached"` | Keeps playing through a native media service. Call `stop()` to stop it. |
| `usage: "music"` (default) | Music playback. |
| `usage: "speech"` | Spoken audio. |

Android can stop a detached session. Ink restores its queue, index, speed and progress in a paused state when you reconnect.

Queue changes, seeks and pauses save immediately; progress saves every 30 seconds. `stop()` clears the queue and saved position.

### Playback speed

```ts
await player.setSpeed(1.25);
```

`state.speed` contains the current speed. The default is `1`; supported values are `0.25` to `4`.

### Keep visual progress native

Playback progress is native by default. Pass `playback={player}` to `PlayingScreen`; it connects the native clock and commands. The bar and elapsed-time label update without periodic React renders.

This component controls an existing queue. Load tracks with `setQueue` before opening it.

```tsx
import { usePlayer } from "@ink/audio";
import { ErrorState, PlayingScreen, Screen, useAction } from "ink";

export function NativeNowPlaying() {
  const player = usePlayer({ session: "main", mode: "detached" });
  const command = useAction(async (run: () => Promise<void>) => run());
  const { state } = player;
  const error = state.error ?? (command.status === "error" ? command.error : null);
  if (error) return <Screen title="Playback"><ErrorState message={error.message}
    onRetry={() => command.run(player.play)} /></Screen>;
  return <PlayingScreen
    title={state.current?.title ?? "Nothing playing"} artists={[]}
    playback={player}
  />;
}
```

<img src="/images/patterns/audio-native-clock.png" alt="Native playback with elapsed time, duration and transport controls." width="1080" height="1240" />

`state.position` and `state.silenceSaved` reflect the last status notification, not a live tick. Queue, playback status, seek and speed changes still notify JavaScript. There is no periodic React progress mode.

Use `await player.getState()` when application logic needs a fresh snapshot, such as saving a bookmark or preserving position while changing sources. This reads native state without notifying React subscribers. Use `player.seekBy(offset)` for relative skips; the offset is a signed integer in milliseconds and is applied to the current native position. For an application-specific live statistic, refresh a local value only while its screen is visible.

When migrating older apps, replace calculations such as `player.seek(player.state.position + 15_000)` with `player.seekBy(15_000)`. Replace progress effects driven by periodic state updates with explicit checkpoints and fresh reads on backgrounding. Pass `playback={player}` to `PlayingScreen`; remove separate clock, position, duration, speed and transport callback props.

### Replace a source without losing progress

Use `replaceSource(id, src)` to move the current item between streaming and a downloaded file. Ink verifies the current item ID, preserves the queue and reads its position and playback intent natively.

```ts
await player.replaceSource(episode.id, downloadedFile.src);
```

To combine a source change with a seek, supply either `{ position: milliseconds }` or `{ offset: milliseconds }`. Both use milliseconds; offsets may be negative. `{ prepare: false }` leaves the replacement stopped and paused, for removing a completed download without starting another request. A changed current item ID rejects rather than replacing a different episode.

### Audio effects

Both effects are off by default and saved per session. They work with streamed and local audio at any playback speed.

| Effect | Behaviour |
| --- | --- |
| Silence trimming | Shortens quiet pauses without changing speech speed. |
| Voice boost | Evens out speech volume and limits peaks. |

```ts
await player.setSkipSilence(true);
await player.setVoiceBoost(true);
```

Read the settings from `state.skipSilence` and `state.voiceBoost`. `state.silenceSaved` is the approximate total time saved in milliseconds, adjusted for playback speed. It survives app restarts and excludes manual seeks.

Connect a toggle with `useAction`:

```tsx
const silence = useAction((enabled: boolean) => player.setSkipSilence(enabled));

<Toggle label="Smart speed" value={player.state.skipSilence}
  onChange={silence.run} />
```

### Sessions

Players with the same `session` name share playback and must use the same `mode` and `usage`. Different names keep separate queues. Starting one session pauses the others.

`session` defaults to `main`. Names accept 1–64 letters, numbers, dots, underscores or hyphens. Up to eight sessions can be active.

`usePlayer` disconnects when the screen is hidden and reconnects to the queue when it returns. Background workers cannot use audio controllers.

## Manage the queue

Use the `player` from `usePlayer` in a button action, after `player.state.ready` is true:

```ts
async function playAlbum() {
  await player.setQueue([
    {
      id: "track-1",
      src: "https://example.com/music/track-1.mp3",
      title: "First track",
      artist: "Artist name",
    },
    {
      id: "track-2",
      src: "https://example.com/music/track-2.mp3",
      title: "Second track",
      artist: "Artist name",
    },
  ], { startIndex: 0 });
  await player.play();
}
```

`setQueue` replaces the current queue. Use your own audio URLs or bundled audio for `src`. Items can also include artwork and a `duration` in milliseconds to display while loading.

To set a queue without loading audio until playback or seeking, pass `prepare: false`:

```ts
await player.setQueue(tracks, { prepare: false });
```

Pass `startPosition` (milliseconds) to `setQueue` to resume without a separate seek.

```ts
await player.setQueue(episodes, { startIndex: 0, startPosition: 33 * 60_000 });
```

### Seek

Positions are in milliseconds:

```ts
await player.seek(30_000);
```

Player commands return promises.

### Playback state

| Field | Meaning |
| --- | --- |
| `ready` | The player is connected and accepts commands. |
| `current` | The current item, or `null` for an empty queue. |
| `playing` | Audio is advancing. |
| `playWhenReady` | Playback is requested, including while buffering. Use this for play/pause controls. |
| `loading` | The current item is buffering before it first becomes ready. |
| `buffering` | Audio is buffering, including during a seek. |
| `ended` | Playback has reached the end of the queue. |
| `position`, `duration` | Times in milliseconds. |
| `error` | The playback error, or `null`. |

Hardware keys, media controls and interruptions update state automatically. [Playing screen](/playing-screen) uses these fields directly.

Playback errors identify source, unsupported-media and output failures. Ink does not automatically skip a failed item.

## Recording and analysis

`@ink/audio/microphone` exports `useLevelMeter`, `usePitchDetector` and `PitchIndicator`. `@ink/audio/recording` exports `useRecorder`.

Each hook returns `ready`, `state` and commands that return promises. Wait for `ready` before enabling controls.

### Display measurements natively

`RecordingDuration`, `LevelReadout` and `PitchReadout` display changing measurements without React updates. Capture hooks expose lifecycle status and errors in `state`; native readouts update automatically.

After granting microphone permission, a recording page can display elapsed time with this binding:

```tsx
import { RecordingDuration, useRecorder } from "@ink/audio/recording";
import { Button, Field, Screen, useAction } from "ink";

export default function Recording() {
  const recorder = useRecorder();
  const command = useAction((run: () => Promise<unknown>) => run());
  return <Screen title="Recording">
    <RecordingDuration recorder={recorder} />
    <Button disabled={!recorder.ready} onPress={() => command.run(recorder.start)}>Start</Button>
    <Button disabled={!recorder.ready || recorder.state.status !== "recording"} onPress={() => command.run(recorder.stop)}>Save</Button>
    {command.status === "error" && <Field label="Error">{command.error.message}</Field>}
  </Screen>;
}
```

| Component | Source | Display |
| --- | --- | --- |
| `RecordingDuration` from `@ink/audio/recording` | `recorder` returned by `useRecorder` | Elapsed minutes and seconds. |
| `LevelReadout` from `@ink/audio/microphone` | `meter` returned by `useLevelMeter` | RMS and peak signal levels. |
| `PitchReadout` from `@ink/audio/microphone` | `detector` returned by `usePitchDetector` | Note, octave, frequency and cents, or a dash when no note is detected. |

The template's recording page uses the native elapsed label; its microphone page uses the level and pitch readouts.

<div className="pattern-preview">
<img src="/images/patterns/audio-recording-native.png" alt="Recording page with a native elapsed-time label showing four seconds." width="1080" height="1240" />
<img src="/images/patterns/audio-level-native.png" alt="Microphone page with native RMS and peak readings." width="1080" height="1240" />
</div>

### Read live measurements

Subscribe to `measurements` only when your component calculates or draws a custom reading:

```tsx
import { usePitchDetector } from "@ink/audio/microphone";
import { useSnapshot } from "ink";

const detector = usePitchDetector();
const measurement = useSnapshot(detector.measurements);
const frequency = measurement.status === "ready" ? measurement.data.frequency : null;
```

This component still needs microphone permission and must call `detector.start()`. The source is stable across renders. Ink enables measurement delivery while subscribed and stops it when the last subscriber leaves, without restarting capture. `state` continues to provide status and errors. Completed recordings include their final duration in `state.recording` and the result of `stop()`.

When migrating, remove `updates` options. Replace reads of live `state.frequency`, `state.rms`, `state.cents` or recording `state.duration` with a `measurements` subscription. Native readouts need no subscription.

### Record audio

Inside your component:

```ts
import { useAction } from "ink";
import { useRecorder } from "@ink/audio/recording";

const recorder = useRecorder();
const start = useAction(() => recorder.start());
```

Call `start.run()` from a button after permission is granted and `recorder.ready` is true. Stop it from a separate action:

```ts
const recording = await recorder.stop();
```

### Detect pitch

Inside your component:

```ts
import { usePitchDetector } from "@ink/audio/microphone";

const detector = usePitchDetector({ referenceHz: 440 });
```

Call its `start()` and `stop()` commands from actions, following the same permission and readiness checks.

| Hook | Commands | State |
| --- | --- | --- |
| `useRecorder()` | `start()`, `stop()`, `cancel()`, `delete()` | Status, error and the saved recording when available. Live duration is in `measurements`. |
| `useLevelMeter()` | `start()`, `stop()` | Status and error. `measurements` includes RMS and peak signal levels, not calibrated sound-pressure measurements. |
| `usePitchDetector({ referenceHz })` | `start()`, `stop()` | Status and error. `measurements` includes frequency in Hz, note, octave, cents and confidence. Reference defaults to 440 Hz. |

### Tuning indicator

Pass `measurement.data.cents` from a ready, active measurement snapshot to `PitchIndicator`, or `null` when no note is detected. Its 25-tick scale spans −50 to +50 cents: flat on the left, sharp on the right. Readings within ±5 cents settle on the taller centre tick. The indicator follows Ink’s colour scheme and is not interactive.

### Saved recordings

Recorder status is `idle`, `recording`, `stopping`, `ready` or `error`. `stop()` resolves with the saved recording after the file has been finalised. It rejects if no recording is in progress. The completed file also appears in state, including when recording stops as the app backgrounds.

The recording is a [FileRef](/files#file-metadata) with a duration in milliseconds. Use its `src` with `usePlayer` or `fetch`.

`cancel()` discards unfinished capture. `delete()` removes the current saved recording.

Earlier recordings remain available. Save their IDs and reopen them with `files.open(id)`. They remain until removed with `files.remove(id)` or app data is cleared.

### Leaving the app

Pitch and level analysis pause in the background and resume when the app returns. Calling `stop()` or removing the hook prevents resumption.

Recordings stop in the background and do not resume automatically. Removing a capture hook releases the microphone.

Recording and analysis cannot use the microphone at the same time. Each import includes only its input engine. Playback includes neither engine nor microphone permission.

## Providers and offline media

Direct audio files and podcast URLs work with this player. Music services may require their own SDK or playback engine, especially for protected or offline content.

Use [Downloads](/downloads) to save audio files. Reopen the file by ID before playback and handle missing files or expired provider access.
