Skip to content

Earshot ​

Sound that knows about walls and rooms, and mic loudness as game input, for 2D web games. A voice behind a wall sounds muffled, a cave echoes, and a shout can wake a monster only if it carries that far. Earshot has no runtime dependencies, is about 3.7 KB gzipped before minifying, and works with any engine that can give it a Web Audio context.

GitHub · 0.2 release post · What a wall does to sound

Sound through walls ​

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

const ctx = new AudioContext(); // Resume it from a tap or key press.
const ear = createListener(ctx, {
  tile: 16,
  pos: () => player,
  world: {
    wallsBetween: (a, b) => wallsOnGrid(tiles, a, b), // tiles[y][x] is "thin", "solid" or null
    reverbAt: (p) => (p.y > 20 ? "cave" : null),
  },
});

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

// In your game loop. 10 to 20 times a second is enough.
ear.update();
radio.state; // "audible" | "out-of-range" | "muted"

A source is any AudioNode, and it stays silent until the first update(). Positions are in your world units, and tile converts them to tiles. Inside wallsBetween and reverbAt, points are already in tiles.

ear.params, ear.materials and ear.reverbs are live, so you can tune them while the game runs. Every source in a reverb shares one convolver, because convolvers are expensive.

Defaults ​

These are starting points tuned by ear on one demo, not measured standards.

SettingDefault
Full volumeUp to 2 tiles
SilenceFrom 12 tiles
PanUp to ±0.6, reached at 8 tiles, from horizontal position only
Thin wall2000 Hz, -6 dB
Solid wall1200 Hz, -12 dB
Two or more walls700 Hz, -18 dB
Room reverb0.4 s, wet -18 dB
Cave reverb1.8 s, wet -12 dB

Loudness as input ​

ts
import { createLoudness } from "@debuffjp/earshot";

const loud = createLoudness(ctx);
loud.onLevel((level) => { if (level >= 2) monster.hear(player); }); // 0 to 3

// From a button: the player stays quiet for 3 seconds while it measures the room.
const { ok, error } = await loud.enableMic();

// Keys work with or without a mic.
loud.press(1);
loud.press(0);

Audio goes to an analyser and no further: Earshot doesn't record it, send it or play it back. Levels are relative to the measured noise floor, at 10, 20 and 30 dB above it, so loud and quiet rooms behave alike.

Always give players a key or button through press(). Don't require words or long strain, and tell players what the mic is for before asking.

Noise through walls ​

To make walls and distance matter in your game rules, work out how loud the noise is where the monster stands. The threshold is yours to tune; 6 dB felt right in our demo.

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

const walls = wallsOnGrid(tiles, player, monster); // Both in tile units.
const mix = computeMix(player, monster, walls, DEFAULT_PARAMS, DEFAULT_MATERIALS);
if (loud.level >= 2 && heardDb(loud.level, mix) >= 6) monster.wake();

Voice chat ​

Added in 0.2. Earshot makes voices sound like they're in your world. Use any RTCPeerConnection you already have, or the voice rooms below. We run no servers for you.

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

const mic = createVoiceMic({ canUseVoice: () => settings.voiceAllowed });
const { ok, error } = await mic.start(); // Call from a tap or key press.
if (ok) mic.stream.getTracks().forEach((track) => connection.addTrack(track, mic.stream));

connection.ontrack = (event) => {
  const voice = ear.voice(event.streams[0], { pos: () => friend });
  // voice.state, voice.speaking and voice.levelDb are there for your UI.
};

mic.muted = true;
mic.pushToTalk = true;
mic.talk(true);

A voice goes through the same walls, rooms and pan as any other source, and voice.muted mutes one player locally. mic.live says whether audio is being sent, so show it to players. canUseVoice is required, and the mic won't open while it returns false: use it for consent checks and flags such as a portal's disableChat.

Voice rooms ​

If you don't have a connection already, joinVoiceRoom() connects players using a small Cloudflare Worker that you deploy to your own account. We don't host it. It gives you invite-only rooms, a player cap and short-lived TURN credentials, and its canJoin function is where your login or ban checks go.

ts
import { createRoomCode, joinVoiceRoom } from "@debuffjp/earshot";

const code = createRoomCode(); // Share it with friends.
const joined = await joinVoiceRoom({
  url: "wss://earshot-voice.you.workers.dev",
  code,
  mic,
  listener: ear,
  ipPolicy: "relay", // Or "direct": see below.
  pos: (peer) => players.get(peer) ?? spawn,
  onMessage: (peer, position) => players.set(peer, position),
});

if (joined.ok) setInterval(() => joined.room.send(player), 66);

ipPolicy is required because it's a privacy decision. "relay" hides IP addresses from other players and fails rather than falling back, but all voice is billed as TURN traffic. "direct" costs less, and players can see each other's IP addresses.

Everyone connects to everyone, so upload grows with each player. Rooms stop sending to players well out of range. Errors come back as joined.error: room-full, no-turn-server, mic-not-started or connection-failed. Dropped connections don't rejoin automatically, and room.connection(peer) lets you show that.

These controls don't make your game compliant. Live voice between players can bring legal duties, especially where children may play: COPPA in the US and the Online Safety Act in the UK, for example. A library in the browser can't verify ages, enforce bans, moderate or take reports, so those are yours to plan for. Keep voice off by default and limit it to friends. This isn't legal advice.

Tested with a PC and an iPhone on one network, and on relay between Chromium and Firefox on desktop. Larger groups are untested.

With debuff ​

Pass debuff's audio context and a bus, so Earshot follows the game's volume and mute.

ts
const ear = createListener(audio.ctx, { output: audio.bus("sfx"), pos: () => player });

Limits ​

  • The mic needs HTTPS or localhost, and an iframe needs allow="microphone".
  • A denied mic returns { ok: false, error }, so your game carries on.
  • Browsers can ignore constraints. Read loud.applied for what you got.
  • Opening the mic can change game audio on iPhones. WebKit bug 311451 reports lower quality and mono output on iOS 26.4 and later. Test on a real phone.
  • Tested on a desktop browser and one iPhone. Android is untested.
  • We host nothing: voice rooms run on your own Worker. Before 0.2 Earshot had no voice chat or networking.

Last updated: