# Sound

This page covers how Earshot decides what a sound does on its way to the player, and how to change those decisions.

## Listeners {#listeners}

`createListener(ctx, options)` takes any `BaseAudioContext`, so an `OfflineAudioContext` works for sources too.

| Option | Meaning | Default |
| --- | --- | --- |
| `pos()` | Listener position in world units. Required, read on every update | |
| `tile` | World units per tile. Positions are divided by this | `1` |
| `output` | Where sound ends up | The context's destination |
| `world` | `wallsBetween` and `reverbAt`, both optional | No walls, no reverb |
| `params` | Overrides for [params](#params) | `DEFAULT_PARAMS` |
| `materials` | Added to, or replacing by name, the [materials](#materials) | `DEFAULT_MATERIALS` |
| `reverbs` | Added to, or replacing by name, the [reverbs](#reverbs) | `DEFAULT_REVERBS` |

Call `ear.update()` from your game loop. 10 to 20 times a second is enough. Changes glide over `params.smoothing` seconds.

## Sources {#sources}

```ts
const radio = ear.source(radioNode, { pos: () => radioPosition });

radio.state; // "audible" | "out-of-range" | "muted"
radio.mix; // distance, gain, cutoff, pan and walls, or null before the first update
radio.reverb; // the reverb it's in, or null
radio.muted = true; // applies on the next update
radio.remove(); // disconnects it; radioNode stays usable
```

A source is silent until the first `update()`, and `state` reads `"audible"` until then. `"out-of-range"` means the gain is exactly 0.

From 0.3, a new source starts at its mix instead of fading in, so a door slam keeps its attack. Before 0.3 a new source faded in over `params.smoothing`.

## Distance and pan {#distance-and-pan}

Volume is full inside `fullRadius` tiles, exactly 0 from `silentRadius` tiles, and follows a smoothstep curve between them. `distanceGain(d, full, silent)` gives you that curve.

Pan uses horizontal position only. It reaches `panWidth` at `panRange` tiles and stays there. A sound directly above or below the listener is centred.

## Walls {#walls}

`world.wallsBetween(a, b)` returns the names of the materials between two points, in tiles. For a nested array, use `wallsOnGrid`:

```ts
const ear = createListener(ctx, {
  tile: 16,
  pos: () => player,
  world: { wallsBetween: (a, b) => wallsOnGrid(tiles, a, b) },
});
```

Both helpers sample the line about 8 times per tile. Adjacent tiles of one material count as one wall, however thick. Touching tiles of two materials count as two walls.

### wallsAlong {#wallsalong}

Added in 0.3. For maps that aren't nested arrays, `wallsAlong` asks you for one tile at a time:

```ts
import { wallsAlong } from "@debuffjp/earshot";

const at = (x: number, y: number) =>
  x >= 0 && y >= 0 && x  player,
  world: { wallsBetween: (a, b) => wallsAlong(at, a, b) },
});
```

Check the bounds yourself with a flat array, as above: without the check, an `x` past the edge reads the next row.

## Materials {#materials}

A material is a low-pass cutoff `hz` and a level change `db`.

| Walls in the way | Used | Cutoff | Level |
| --- | --- | --- | --- |
| None | Nothing | 20000 Hz | 0 dB |
| One `thin` | `materials.thin` | 2000 Hz | -6 dB |
| One `solid` | `materials.solid` | 1200 Hz | -12 dB |
| Two or more of any kind | `params.multiWall` | 700 Hz | -18 dB |

Add your own by name:

```ts
ear.materials.glass = { hz: 5000, db: -3 };
```

A single wall with a name that isn't in `materials` throws `earshot: unknown material "name"` during `update()`. Two or more walls use `multiWall`, so their names aren't looked up.

These defaults were tuned by ear on one demo. [What a wall does to sound](/research/what-a-wall-does-to-sound) measures their effect on the signal.

## Reverbs {#reverbs}

`world.reverbAt(p)` returns the name of the reverb a source sits in, or nothing for a dry sound. The point is in tiles, and it's the source's position that counts.

```ts
const ear = createListener(ctx, {
  pos: () => player,
  reverbs: { hall: { seconds: 2.5, wetDb: -10 } },
  world: { reverbAt: (p) => (p.y > 20 ? "cave" : "hall") },
});
```

| Reverb | Length | Wet level |
| --- | --- | --- |
| `room` | 0.4 s | -18 dB |
| `cave` | 1.8 s | -12 dB |

Every source in a reverb shares one convolver, because convolvers are expensive. The convolver is built the first time something enters that reverb, so `seconds` is fixed from then on, while `wetDb` keeps applying on each update. An unknown name throws `earshot: unknown reverb "name"`.

## Params {#params}

| Param | Meaning | Default |
| --- | --- | --- |
| `fullRadius` | Full volume inside this many tiles | `2` |
| `silentRadius` | Exactly silent from this many tiles | `12` |
| `panWidth` | Largest pan, 0 to 1 | `0.6` |
| `panRange` | Tiles of horizontal offset that reach `panWidth` | `8` |
| `smoothing` | Time constant for changes, in seconds | `0.08` |
| `multiWall` | The material used for two or more walls | `{ hz: 700, db: -18 }` |

## Tuning {#tuning}

`ear.params`, `ear.materials` and `ear.reverbs` are live copies, so edits apply on the next update and never touch the `DEFAULT_` constants.

```ts
ear.params.silentRadius = 20;
ear.params.multiWall.hz = 500;
ear.materials.thin = { hz: 3000, db: -4 };
ear.reverbs.cave.wetDb = -8;
```

## Without Web Audio {#without-web-audio}

`computeMix` returns the whole decision as plain numbers, so you can show it or test it with no audio context. Both points are in tiles.

```ts
import { computeMix, DEFAULT_MATERIALS, DEFAULT_PARAMS } from "@debuffjp/earshot";

const mix = computeMix({ x: 0, y: 0 }, { x: 8, y: 0 }, ["solid"], DEFAULT_PARAMS, DEFAULT_MATERIALS);
mix.cutoff; // 1200
mix.pan; // 0.6
```