# サウンド

音がプレイヤーに届くまでに Earshot が何を決めているか、その決め方をどう変えるかを説明します。

## リスナー {#listeners}

`createListener(ctx, options)` は任意の `BaseAudioContext` を受け取ります。音源だけなら `OfflineAudioContext` でも動きます。

| オプション | 意味 | デフォルト |
| --- | --- | --- |
| `pos()` | ワールド座標でのリスナーの位置。必須で、更新のたびに読み取られます | |
| `tile` | 1 タイルあたりのワールド単位。位置をこの値で割ります | `1` |
| `output` | 音の出力先 | コンテキストの destination |
| `world` | `wallsBetween` と `reverbAt`。どちらも省略できます | 壁なし、リバーブなし |
| `params` | [パラメータ](#params)の上書き | `DEFAULT_PARAMS` |
| `materials` | [素材](#materials)の追加、または同じ名前の置き換え | `DEFAULT_MATERIALS` |
| `reverbs` | [リバーブ](#reverbs)の追加、または同じ名前の置き換え | `DEFAULT_REVERBS` |

ゲームループから `ear.update()` を呼びます。1 秒に 10〜20 回で十分です。変化は `params.smoothing` 秒かけてなめらかにつながります。

## 音源 {#sources}

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

radio.state; // "audible" | "out-of-range" | "muted"
radio.mix; // distance、gain、cutoff、pan、walls。最初の更新までは null
radio.reverb; // 音源がいるリバーブ、または null
radio.muted = true; // 次の更新で反映されます
radio.remove(); // 切り離します。radioNode は引き続き使えます
```

音源は最初の `update()` まで無音で、それまでの `state` は `"audible"` です。`"out-of-range"` はゲインがちょうど 0 であることを表します。

0.3 から、新しい音源はフェードインせず、最初からミックスどおりの状態で始まります。そのためドアの閉まる音はアタックを保ちます。0.3 より前の新しい音源は `params.smoothing` でフェードインしていました。

## 距離とパン {#distance-and-pan}

音量は `fullRadius` タイルの内側で最大、`silentRadius` タイルからちょうど 0 になり、その間は smoothstep の曲線に沿って変わります。`distanceGain(d, full, silent)` でこの曲線を求められます。

パンは水平位置だけを使います。`panRange` タイルで `panWidth` に達し、それ以上は変わりません。リスナーの真上や真下の音は中央から聞こえます。

## 壁 {#walls}

`world.wallsBetween(a, b)` は、2 点の間にある素材の名前をタイル座標で返します。入れ子の配列には `wallsOnGrid` を使います。

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

どちらのヘルパーも、線分を 1 タイルあたり約 8 回調べます。同じ素材のタイルが隣り合っていれば、厚さによらず壁 1 枚と数えます。違う素材が接していれば 2 枚です。

### wallsAlong {#wallsalong}

0.3 で追加しました。入れ子の配列ではないマップには `wallsAlong` を使います。タイルを 1 つずつ問い合わせます。

```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) },
});
```

フラットな配列では、上の例のように範囲を自分で確認してください。確認しないと、端を越えた `x` が次の行を読んでしまいます。

## 素材 {#materials}

素材は、ローパスのカットオフ `hz` と音量の変化 `db` の組です。

| 間にある壁 | 使われる値 | カットオフ | 音量 |
| --- | --- | --- | --- |
| なし | なし | 20000 Hz | 0 dB |
| `thin` が 1 枚 | `materials.thin` | 2000 Hz | -6 dB |
| `solid` が 1 枚 | `materials.solid` | 1200 Hz | -12 dB |
| 種類を問わず 2 枚以上 | `params.multiWall` | 700 Hz | -18 dB |

名前をつけて追加できます。

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

`materials` にない名前の壁が 1 枚だけあると、`update()` の中で `earshot: unknown material "name"` を投げます。2 枚以上の壁は `multiWall` を使うので、名前を調べません。

これらのデフォルト値は、1 つのデモで耳を頼りに調整したものです。信号への影響を[壁は音に何をするのか](/ja/research/what-a-wall-does-to-sound)で測定しています。

## リバーブ {#reverbs}

`world.reverbAt(p)` は音源がいるリバーブの名前を返します。乾いた音にするなら何も返しません。座標はタイル単位で、基準になるのは音源の位置です。

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

| リバーブ | 長さ | ウェット |
| --- | --- | --- |
| `room` | 0.4 秒 | -18 dB |
| `cave` | 1.8 秒 | -12 dB |

コンボルバーは負荷が高いので、同じリバーブの音源は 1 つのコンボルバーを共有します。最初にそのリバーブが使われたときにコンボルバーを作るので、以後 `seconds` は変わりません。`wetDb` は更新のたびに反映されます。未知の名前は `earshot: unknown reverb "name"` を投げます。

## パラメータ {#params}

| パラメータ | 意味 | デフォルト |
| --- | --- | --- |
| `fullRadius` | このタイル数の内側で最大音量 | `2` |
| `silentRadius` | このタイル数からちょうど無音 | `12` |
| `panWidth` | パンの最大値（0〜1） | `0.6` |
| `panRange` | `panWidth` に達する水平距離（タイル） | `8` |
| `smoothing` | 変化の時定数（秒） | `0.08` |
| `multiWall` | 壁が 2 枚以上のときの素材 | `{ hz: 700, db: -18 }` |

## 調整 {#tuning}

`ear.params`、`ear.materials`、`ear.reverbs` はライブなコピーです。変更は次の更新で反映され、`DEFAULT_` の定数には影響しません。

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

## Web Audio なしで使う {#without-web-audio}

`computeMix` は判断の全体を数値で返します。オーディオコンテキストなしで、表示やテストに使えます。座標はどちらもタイル単位です。

```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
```