Skip to content

Earshot with debuff ​

@debuffjp/plugin-earshot connects Earshot to your game: walls come from the tilemap, sounds follow the sfx bus, and scenes clean up after themselves.

Install ​

bash
npm install @debuffjp/plugin-earshot

It needs @debuffjp/audio with audio.buffer(), which arrives in the release after 0.29.2. On older versions, use the manual hookup.

Set up ​

ts
import { earshot } from "@debuffjp/plugin-earshot";

const ear = earshot({ audio, map, pos: () => player });
game.use(ear);

pos is in world units, as it is everywhere in Earshot.

OptionMeaningDefault
audioYour game's audio. Required
pos()Listener position. Required
mapA tilemap whose collision grid gives the wallsNone
material(x, y)A tile's material, when you want your own names"solid" for solid tiles
wallsBetween(a, b)Your own wall check, used only when there's no map. Points are in tilesNone
reverbAt(p)The reverb at a point, in tilesNone
tileWorld units per tileThe map's tile width, or 1
bus"sfx" or "music", so sounds follow that bus's volume and mute"sfx"
params, materials, reverbsStarting values, as on createListenerEarshot's defaults

The map is read live, so an opened door lets sound through. One-way platforms aren't walls. Tiles are treated as square, using tileWidth. Tiles outside the map are open air.

ts
const ear = earshot({
  audio,
  map,
  pos: () => player,
  material: (x, y) => (map.collision[y * map.width + x] === 1 ? (isGlass(x, y) ? "thin" : "solid") : null),
  reverbAt: (p) => (p.y > 20 ? "cave" : null),
});

material replaces the default check, so test for solid tiles yourself as above.

play ​

Decode a sound first, then play it at a position. at is a point, or a function for something that moves.

ts
await audio.decode("door", assets.audio("door"));

ear.play("door", { at: door });
const hum = ear.play("radio", { at: () => radio, loop: true, volume: 0.8, rate: 1 });

hum.source.state; // "audible" | "out-of-range" | "muted"
hum.stop();

play returns the Earshot source and a stop(). A sound that isn't decoded throws an error saying so. The sound gets its mix as it starts, so it never plays a moment unfiltered.

source ​

Send your own AudioNode through the map.

ts
const source = ear.source(node, { at: () => jukebox, name: "jukebox" });

name labels it in inspect and defaults to "source". On a scene change it's disconnected, and starting or stopping the node stays up to you.

material and walls ​

materials adds or replaces wall types by name, and material(x, y) says which one each tile is. Without a map, pass wallsBetween instead:

ts
const ear = earshot({
  audio,
  pos: () => player,
  materials: { glass: { hz: 5000, db: -3 } },
  wallsBetween: (a, b) => wallsOnGrid(tiles, a, b),
});

The default values and the two-or-more-walls rule are in Sound.

params ​

fullRadius, silentRadius and panWidth appear in a param group called earshot, so you and your agents can tune them while the game runs. The plugin copies them to the listener on every third game update, then updates the sounds, so set these three through params and not on ear.listener.params.

ts
game.params.group("earshot")?.set("silentRadius", 20);

Everything else stays live on the listener:

ts
ear.listener.params.smoothing = 0.2;
ear.listener.materials.thin = { hz: 3000, db: -4 };

inspect ​

__DEBUFF_DEV__.state().plugins.earshot lists each sound's state, distance, gain, cutoff, walls and reverb, so scenarios can check what a player hears.

json
{
  "sounds": [
    { "name": "door", "state": "audible", "distance": 7, "gain": 0.13, "cutoff": 1200, "walls": ["solid"], "reverb": null }
  ]
}

Scene cleanup ​

A sound is removed when it ends, when you stop it, or when the scene changes. A sound started in the new scene's enter() belongs to that scene and stays. game.dispose() removes everything, including the listener.

Voices and the listener ​

ear.listener is the Earshot listener, for voices and anything else the plugin doesn't wrap. Pass it to voice rooms as listener: ear.listener. Voices added this way aren't in inspect and don't end with the scene, so remove them yourself. The plugin updates them along with everything else.

Manual hookup ​

To use Earshot with debuff directly, pass debuff's audio context and a bus, so Earshot follows the game's volume and mute. Update it from a hook.

ts
import { createListener, wallsOnGrid } from "@debuffjp/earshot";

const ear = createListener(audio.ctx, {
  output: audio.bus("sfx"),
  tile: 16,
  pos: () => player,
  world: { wallsBetween: (a, b) => wallsOnGrid(tiles, a, b) },
});

const off = game.hooks.afterUpdate(() => ear.update());

const buffer = await audio.ctx.decodeAudioData(assets.audio("door"));
const node = new AudioBufferSourceNode(audio.ctx, { buffer });
const door = ear.source(node, { pos: () => doorPosition });
node.onended = () => door.remove();
ear.update();
node.start();

Here scene cleanup is yours: call door.remove() when you leave, and off() and ear.dispose() when you're done.