Sound
This page covers how Earshot decides what a sound does on its way to the player, and how to change those decisions.
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 | DEFAULT_PARAMS |
materials | Added to, or replacing by name, the materials | DEFAULT_MATERIALS |
reverbs | Added to, or replacing by name, the 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
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 usableA 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
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
world.wallsBetween(a, b) returns the names of the materials between two points, in tiles. For a nested array, use wallsOnGrid:
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
Added in 0.3. For maps that aren't nested arrays, wallsAlong asks you for one tile at a time:
import { wallsAlong } from "@debuffjp/earshot";
const at = (x: number, y: number) =>
x >= 0 && y >= 0 && x < width && y < height && grid[y * width + x] === 1 ? "solid" : null;
const ear = createListener(ctx, {
tile: 16,
pos: () => 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
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:
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 measures their effect on the signal.
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.
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
| 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
ear.params, ear.materials and ear.reverbs are live copies, so edits apply on the next update and never touch the DEFAULT_ constants.
ear.params.silentRadius = 20;
ear.params.multiWall.hz = 500;
ear.materials.thin = { hz: 3000, db: -4 };
ear.reverbs.cave.wetDb = -8;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.
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