Skip to content

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.

OptionMeaningDefault
pos()Listener position in world units. Required, read on every update
tileWorld units per tile. Positions are divided by this1
outputWhere sound ends upThe context's destination
worldwallsBetween and reverbAt, both optionalNo walls, no reverb
paramsOverrides for paramsDEFAULT_PARAMS
materialsAdded to, or replacing by name, the materialsDEFAULT_MATERIALS
reverbsAdded to, or replacing by name, the reverbsDEFAULT_REVERBS

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

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 ​

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:

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 ​

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 < 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 wayUsedCutoffLevel
NoneNothing20000 Hz0 dB
One thinmaterials.thin2000 Hz-6 dB
One solidmaterials.solid1200 Hz-12 dB
Two or more of any kindparams.multiWall700 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 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.

ts
const ear = createListener(ctx, {
  pos: () => player,
  reverbs: { hall: { seconds: 2.5, wetDb: -10 } },
  world: { reverbAt: (p) => (p.y > 20 ? "cave" : "hall") },
});
ReverbLengthWet level
room0.4 s-18 dB
cave1.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 ​

ParamMeaningDefault
fullRadiusFull volume inside this many tiles2
silentRadiusExactly silent from this many tiles12
panWidthLargest pan, 0 to 10.6
panRangeTiles of horizontal offset that reach panWidth8
smoothingTime constant for changes, in seconds0.08
multiWallThe 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.

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 ​

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