# Audio

> The audio mixer (useAudioMixer, AudioMixer, AudioListener) with buses, positional sounds, random pitch, voice limits, ducking and music crossfades, plus the mixing maths and RaceAudio.

The engine's audio (`src/engine/audio/`) is a mixer on `react-native-audio-api` (the Web Audio API on React Native). Use one mixer per game screen for every sound: effects placed in the world (louder when near, panned to their side), random pitch so repeats do not sound copied, a few voices per sound, music that ducks under loud effects and crossfades between tracks, and volume settings saved on the device.

For a flat game with a handful of short sounds, the 2D layer's `useOneShots` (on `expo-audio`) is lighter ([The 2D layer](https://docs.moonspell.app/engine/2d.md)).

## Smallest working example

Music on its bus, a positional pop, and a thud that ducks the music, as the Lantern Tag example does:

```tsx
import React, { useEffect } from 'react';
import { View } from 'react-native';
import { AudioListener, EngineCanvas, useAudioMixer, type GameScreenProps } from '@/engine';

const SOUNDS = {
  music: require('../../assets/audio/music-synthwave.m4a'),
  lantern: require('../../assets/sfx/pop.wav'),
  tagged: require('../../assets/audio/bump.m4a'),
};

export const Screen = (_props: GameScreenProps) => {
  const mixer = useAudioMixer(SOUNDS);

  useEffect(() => {
    mixer?.playMusic('music', { volume: 0.55, fade: 2 });
  }, [mixer]);

  const onLantern = (x: number, z: number) => mixer?.play('lantern', { at: { x, y: 1, z } });
  const onTagged = () => mixer?.play('tagged', { duck: true, pitchJitter: 0.08 });

  return (
    <View style={{ flex: 1 }}>
      <EngineCanvas>
        <AudioListener mixer={mixer} />
        {/* the scene; call onLantern / onTagged from gameplay */}
      </EngineCanvas>
    </View>
  );
};
```

`useAudioMixer` returns `null` while the sounds decode (and if audio cannot start), so call it with `mixer?.`. It closes the mixer when the screen unmounts, so nothing keeps playing behind the games list. `<AudioListener>` inside the canvas makes the camera the listener every frame.

Define the sounds object outside the component (or memoise it). The mixer is recreated when the set of names and assets changes.

## `useAudioMixer(sounds)`

| Argument | Type | What it is |
| --- | --- | --- |
| `sounds` | `Record<string, number>` | Names to `require()`d audio files (`.wav`, `.m4a`) |

Returns `AudioMixer | null`. In development it adds an `audio` line to the debug overlay (sounds loaded, voices playing, music track, ducked, muted).

## `AudioMixer`

| Method | What it does |
| --- | --- |
| `play(name, options?)` | Play a sound. Returns a `Voice`, or `null` when the sound is not loaded or the mixer is closed |
| `playMusic(name, { fade?, volume?, loop? })` | Start a track on the music bus, crossfading from the current one. `fade` 1.2 s, `volume` 1, `loop` true by default. Playing the same track again does nothing |
| `stopMusic(fade = 0.8)` | Fade the music out and stop it |
| `setListener({ x, y, z, yaw })` | Where the ears are; moves every positional sound. `<AudioListener>` calls it for you |
| `getSettings()` | The current `AudioSettings` |
| `setSettings(change)` | Change and save settings: `{ muted: true }`, `{ music: 0.4 }` |
| `load(sounds)` | Decode more sounds later (a level's music). Files that fail are skipped with a warning |
| `has(name)` | Whether a sound is decoded |
| `duration(name)` | Seconds, or 0 when not loaded |
| `dispose()` | Stop everything and close the audio context. `useAudioMixer` calls it on unmount |

Read-only state for telemetry: `voiceCount`, `loadedCount`, `musicTrack`, `ducked`, `played`.

`AudioMixer.create(sounds)` is the async factory behind the hook, for code outside React.

### `PlayOptions`

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `bus` | `'music'`, `'sfx'`, `'ui'` | `'sfx'` | Which volume it obeys |
| `volume` | `number` | `1` | 0 to 1; more boosts |
| `at` | `{ x, y?, z }` | none | Place the sound in the world: louder near the listener, panned to its side |
| `falloff` | `{ near?, far?, rolloff? }` | `near` 2, `far` 40, `rolloff` 1 | Full volume inside `near` metres, silent beyond `far` |
| `pitch` | `number` | `1` | Playback rate (2 is an octave up) |
| `pitchJitter` | `number` | `0.04` for effects, `0` for music | Random pitch, a fraction (0.06 is plus or minus 6 percent) |
| `volumeJitter` | `number` | `0.1` for effects, `0` for music | Random volume, a fraction |
| `loop` | `boolean` | `false` | Loop it (an engine, a fire, ambience) |
| `maxVoices` | `number` | `4` | Voices of this sound at once; a new one past the limit stops the oldest |
| `duck` | `boolean` | `false` | Lower the music while it plays (explosions, a big hit). Ignored on loops |

### `Voice`

| Member | What it does |
| --- | --- |
| `stop(fade?)` | Fade out over `fade` seconds (default 0.05) and stop |
| `setPosition(at)` | Move a looping positional sound |
| `ended` | `true` once it stopped |

### `AudioSettings`

`{ muted, master, music, sfx, ui }`, defaults `false`, `1`, `0.7`, `1`, `0.8`. They are saved per game on the device. The player's game settings from the save module (`sound`, `music`, `volume`, see [Saves](https://docs.moonspell.app/engine/saves.md)) gate the buses on top: sound off silences `sfx` and `ui`, music off silences `music`, and `volume` scales the master.

## The mixing maths

The pure functions behind the mixer are exported for your own sound code and tests:

| Export | What it does |
| --- | --- |
| `attenuation(distance, falloff?)` | Volume 0 to 1 for a distance: 1 inside `near`, inverse distance after, eased to 0 at `far` |
| `panFor(listener, at)` | Stereo pan -1 (left) to 1 (right) of a point heard by the listener |
| `jitter(base, amount, rand?)` | `base` moved by up to plus or minus `amount` (a fraction) |
| `semitones(n)` | Semitones to a playback rate (12 is 2) |
| `VoicePool` | At most `max` voices; `admit(voice, now)` returns the oldest voice to stop when full |
| `Ducker` | Music ducking state: `duck(start, duration)`, `ducked(now)`, `gainAt(now)` |

Types: `Listener` is `{ x, y, z, yaw }` with yaw 0 facing +z; `Falloff` is `{ near?, far?, rolloff? }`.

## `RaceAudio`

The racing module's sound: two engine loops crossfaded and pitched by speed, a tyre squeal when cornering or braking hard, one-shots for accelerating, braking, barrier bumps and rivals passing, and a music bed. It plays the template's files in `assets/audio/` through `expo-audio`. You only need it if you build your own racing game outside `defineRaceGame`:

```ts
import { RaceAudio } from '@/engine';

const audio = new RaceAudio();

export async function startRace() {
  await audio.start();
}

/** Call once per simulation frame. */
export function raceFrame(speedRatio: number, throttle: number, brake: number, steer: number, bump: number, dt: number) {
  audio.update({ speedRatio, throttle, brake, steer: Math.abs(steer), bump, racing: true, dt });
}

/** A rival went past the player. */
export function rivalPassed(relativeSpeed: number) {
  audio.passBy(relativeSpeed);
}

export function endRace() {
  audio.dispose();
}
```

Other members: `setMuted(muted)`, `muted`, `musicVolume` (0.45) and `stats` (one-shot counts for telemetry).

## Gotchas

- Off screen in the Moonspell app's feed, the mixer suspends its audio context and `RaceAudio` pauses its loops, so nothing plays from a card that is not shown. Outside the app the game is always active.
- Keep footsteps and other frequent sounds short, soft and limited: `maxVoices: 2` and some `pitchJitter`.
- Trim and level generated audio before you ship it. The template's sound-design skill has an `ffmpeg` recipe (`silenceremove` and `loudnorm`). Players download every asset with the game, so keep the total small.
- Every sound goes through `require()`. Never build a path or URL to an audio file by hand: the web build names assets by address.
- Browsers hold audio back until the page gets a user gesture (their autoplay policy): a game's mixer starts suspended and is silent until then. In a browser the mixer resumes on the first tap, click or key, and `RaceAudio` starts its loops then (not while the app keeps the game off screen). Phones start sound without a gesture, as before.
