Skip to content

Voice ​

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.

Voice chat ​

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

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

A voice goes through the same walls, rooms and pan as any other source, and needs ear.update() in the same way. ear.voice() needs an AudioContext, not an OfflineAudioContext.

On a voiceMeaning
voice.mutedMutes this player locally
voice.levelDbTheir level before distance and walls, in dB
voice.speakingFor a talking indicator. Ignores distance and walls, so check state if range matters
voice.state, voice.mix, voice.reverbThe same as on any source
voice.remove()Disconnects this voice

speaking turns on above speakingDb, an option of ear.voice() that defaults to -50.

Mic controls ​

The mic is off until the game asks, and it records nothing.

ts
mic.muted = true;
mic.pushToTalk = true;
mic.talk(true); // Hold to talk, while pushToTalk is on.
mic.talk(false);
mic.live; // True when audio is being sent. Show players this.
mic.stop();

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. start() then returns { ok: false, error: "not-allowed" }. It's checked when you call start(), so if a player's permission goes away later, call mic.stop() yourself.

start() never rejects. If the browser refuses, error is the browser's error name.

OptionMeaningDefault
canUseVoice()Your check that this player may use voice. Required
echoCancellationtrue, false, "all" or "remote-only". "all" also asks the browser to cancel other audio where it cantrue
pushToTalkWhile on, the mic only sends during talk(true)false

mic.applied says what the browser really applied. Noise suppression and auto gain are always requested.

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, type Point } 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 as Point),
});

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

mic must have started before you join. pos is called for every player, including before their first position arrives, so give it a fallback. Anyone with the code can join, so share it only with friends.

joinVoiceRoom() never rejects. ok: true means you're in the room, while connections to each player may still be forming.

On the optionsMeaning
onPeer(peer, voice)A player's voice arrived
onLeave(peer)A player left. Also called for everyone when you leave
onMessage(peer, message)A small message from room.send. It can arrive late or not at all
On the roomMeaning
room.idThis player's id
room.peersEach player's Voice, by id
room.connection(peer)Connection state for your UI: "connecting", "connected", "failed" and so on
room.send(message)Sends JSON to every connected player, directly rather than through your Worker
room.leave()Leaves the room. The mic stays open until mic.stop()

ipPolicy ​

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.

Self-hosting has the cost table.

Connections and errors ​

Everyone connects to everyone, so upload grows with each player. Rooms stop sending to players well out of range, which means further than 1.25 times silentRadius.

Errors come back as joined.error:

ErrorMeaning
room-fullThe room is at the Worker's MAX_PLAYERS
no-turn-serveripPolicy is "relay" and the Worker offered no TURN server
mic-not-startedmic.start() hasn't succeeded
connection-failedThe Worker couldn't be reached or refused the connection

Dropped connections don't rejoin automatically, and room.connection(peer) lets you show that.

Your duties ​

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 on ​

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

Earshot 0.2: voice chat that hears your walls introduces these features.