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
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 voice | Meaning |
|---|---|
voice.muted | Mutes this player locally |
voice.levelDb | Their level before distance and walls, in dB |
voice.speaking | For a talking indicator. Ignores distance and walls, so check state if range matters |
voice.state, voice.mix, voice.reverb | The 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.
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.
| Option | Meaning | Default |
|---|---|---|
canUseVoice() | Your check that this player may use voice. Required | |
echoCancellation | true, false, "all" or "remote-only". "all" also asks the browser to cancel other audio where it can | true |
pushToTalk | While 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.
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 options | Meaning |
|---|---|
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 room | Meaning |
|---|---|
room.id | This player's id |
room.peers | Each 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:
| Error | Meaning |
|---|---|
room-full | The room is at the Worker's MAX_PLAYERS |
no-turn-server | ipPolicy is "relay" and the Worker offered no TURN server |
mic-not-started | mic.start() hasn't succeeded |
connection-failed | The 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.