Voice chat
Call joinVoice(room) to join voice chat with other players in the room. Call it from a button so the player chooses when to enable their microphone.
import { joinRoom, joinVoice } from '@irtio/client';
import { schema } from './irtio/schema';
const room = await joinRoom(schema);
document.querySelector<HTMLButtonElement>('#voice')!.onclick = async () => {
const voice = await joinVoice(room);
// Keep this handle for your mute and leave controls.
voice.mute(false);
}; The page needs a button with id="voice". The browser asks for microphone permission when the player joins. Handle a rejected join by showing the error and letting the player try again.
Handling a failed join
joinVoice rejects with a VoiceError. Branch on err.code, not on the message text.
import { VoiceError, joinVoice } from '@irtio/client';
try {
voice = await joinVoice(room);
} catch (err) {
if (!(err instanceof VoiceError)) throw err;
switch (err.code) {
case 'E_VOICE_UNAVAILABLE':
hideVoiceButton();
break;
case 'E_VOICE_MIC_DENIED':
showMicHelp();
break;
case 'E_VOICE_ROOM_FULL':
showCallFull();
break;
default:
showVoiceError(err.message);
}
} err.code | Meaning |
|---|---|
E_VOICE_UNAVAILABLE | This project has no voice server. Hide the voice button. |
E_VOICE_ROOM_FULL | The call has no free place. |
E_VOICE_PROJECT_FULL | The project is at its cap on voice participants or on rooms with a call in progress. |
E_VOICE_AUDIENCE | The player holds an audience (viewer) seat, which cannot join voice. |
E_VOICE_MIC_DENIED | The player or the page’s permissions refused the microphone. The browser’s NotAllowedError is on err.cause. |
E_VOICE_NO_MIC | No usable microphone was found. |
E_VOICE_NO_MEDIA | The browser has no microphone API here, usually because the page is not served over HTTPS or localhost. |
E_VOICE_UNSUPPORTED | This browser cannot run voice chat. |
E_VOICE_FAILED | The voice server failed the request. Trying again later may work. |
A failed join never affects the game connection.
Controls
| Member | Purpose |
|---|---|
voice.mute(true) | Stop sending audio while continuing to listen |
voice.muted | Read the current mute setting |
voice.peers | Read the client ids in the call |
voice.setPeerVolume(peerId, volume) | Set local playback volume for one peer, 0 to 1 |
voice.mutePeer(peerId, true) | Silence one peer on this machine |
voice.peerState(peerId) | Read one peer’s mute and volume state |
voice.peerLevel(peerId) | Read how much sound that participant is making right now, 0 to 1 |
voice.setListener(pose) | Set where you hear from, with positional |
voice.setPeerPosition(peerId, position) | Place one peer in the world, with positional |
voice.setPeerFilter(peerId, filter) | Filter how you hear one peer |
await voice.setMicFilter(filter) | Filter your microphone for everyone |
voice.micFilter | Read the current microphone filter |
voice.noiseReduction | Read the noise reduction running on your microphone |
voice.on(event, cb) | Subscribe to call events, returns an unsubscribe function |
await voice.leave() | Leave voice chat while staying in the game |
Your own mute is signalled to the server, which pauses the microphone at the source and tells the other participants. The per-peer controls below, including positions and peer filters, are local only.
Audio is served from the room’s region. Everyone in the call hears the same room-wide conversation. Voice availability depends on the region; an unavailable call rejects without affecting the game connection.
irtio dev does not run a voice server, so joinVoice rejects with E_VOICE_UNAVAILABLE locally. Keep voice behind a button that hides on that code, and try the call on a deployed project.
Per-peer playback
Peer ids are room client ids, so you can match a peer to a player in your game state.
setPeerVolume(peerId, volume) sets how loudly this machine plays that peer. Values outside 0 to 1 are clamped, nothing is signalled, and other participants hear that peer unchanged. Calling it with a value the peer already has does nothing, so it is safe to call every frame from a render loop. A volume set before that peer’s audio has arrived is applied when it arrives.
mutePeer(peerId, muted) silences that peer on this machine. It is separate from the peer’s own microphone mute, and it is never signalled.
peerState(peerId) returns the current state, or undefined when nobody with that id is in the call.
| Field | Meaning |
|---|---|
muted | That peer’s own microphone mute, as last reported by the server |
mutedLocally | Your local silencing of that peer on this machine |
volume | Your local playback volume for that peer, 0 to 1 |
position | Where setPeerPosition placed that peer, or null |
filter | The filter setPeerFilter set on that peer, or null |
Use muted and mutedLocally to show whether the peer muted their microphone or you muted playback.
Events
voice.on(event, cb) returns a function that removes the listener.
| Event | Fires when |
|---|---|
peer-joined | A peer enters the call, with the peer id |
peer-left | A peer leaves the call, with the peer id |
peer-muted | A peer changes their own microphone mute, with { peerId, muted } |
peers-changed | Anything above changed, or you changed a local volume or mute |
speaking | A participant starts or stops making sound, with { peerId, speaking } |
error | The call reports an error, with { code, message } |
Render a roster from peers-changed. It covers every change to the list and to peer state in one notification, and it is coalesced to one call per microtask, so a join that arrives as two updates redraws the UI once.
const stop = voice.on('peers-changed', () => {
renderRoster(voice.peers.map((id) => ({ id, ...voice.peerState(id)! })));
}); Who is talking
voice.peerLevel(peerId) returns the participant’s audio level from 0 to 1, measured locally
from their audio track. Call it once per render frame to update a meter or an avatar ring.
This excerpt assumes your UI provides setRingScale(id, scale):
for (const id of [room.me, ...voice.peers]) {
setRingScale(id, 1 + voice.peerLevel(id) * 0.5);
} Pass a room client id as peerId. Use room.me for your own microphone.
Listen for speaking to highlight a participant while they talk. The event delays both the start
and stop of the indicator to avoid flickering between syllables. This excerpt assumes nameplate(peerId) returns the participant’s HTML element:
const stop = voice.on('speaking', ({ peerId, speaking }) => {
nameplate(peerId).classList.toggle('talking', speaking);
}); peerLevel returns 0 for an unknown participant, a missing audio track, or your own muted
microphone. A peer silenced locally with mutePeer still reports their audio level. Without Web
Audio support, levels are 0 and speaking events do not fire.
Positional voice
Join with positional to pan each peer left or right and fade them with distance. Then tell the
call where everyone is from your render loop. This excerpt assumes world.players contains each
player’s position, keyed by room client id:
const voice = await joinVoice(room, {
positional: { distanceModel: 'linear', refDistance: 4, maxDistance: 20 },
});
function updateVoicePositions() {
const me = world.players[room.me];
if (!me) return;
voice.setListener({ x: me.x, y: me.y, z: me.z, forward: me.facing });
for (const peerId of voice.peers) {
const them = world.players[peerId];
voice.setPeerPosition(peerId, them ? { x: them.x, y: them.y, z: them.z } : null);
}
} Positions are in your game’s units. Repeated calls glide to the new value, and a call with an
unchanged position does no work. A peer with no position, or one you set to null, plays without
panning at their setPeerVolume. A position set before a peer’s audio arrives is applied when it
arrives.
For a 2D game, pass x and y and leave out z, forward and up. A peer at a larger x than
the listener is heard on the right.
| Option | Default | Meaning |
|---|---|---|
panningModel | 'HRTF' | 'HRTF' for 3D sound on headphones, 'equalpower' for plain stereo panning |
distanceModel | 'inverse' | 'linear', 'inverse' or 'exponential' volume falloff |
refDistance | 1 | Distance, in your units, at which falloff starts |
maxDistance | 10000 | Distance at which falloff stops. With 'linear', the peer is silent from here |
rolloffFactor | 1 | How quickly volume falls off |
Pass positional: true to take every default. The defaults suit a world measured in metres, so a
game measured in pixels should set refDistance and maxDistance.
setListener takes { x, y, z?, forward?, up? }. forward defaults to { x: 0, y: 0, z: -1 } and up to { x: 0, y: 1, z: 0 }. setPeerPosition and setListener do nothing without positional; setPeerPosition logs a warning once.
setPeerVolume still applies on top of distance falloff, so a volume slider and positional audio
work together.
Voice filters
setPeerFilter(peerId, filter) changes how you hear one peer, for example muffled behind a wall
or crackling over a radio. It is local, like setPeerVolume. setMicFilter(filter) filters your
own microphone before it is sent, so every participant hears it.
// Bo is on the other side of a door.
voice.setPeerFilter(boId, 'muffled');
voice.setPeerFilter(boId, null); // the door opens
// Everyone hears you over a radio while you hold the radio key.
await voice.setMicFilter(holdingRadio ? 'radio' : null); | Preset | Sound |
|---|---|
'muffled' | Behind a wall or door |
'telephone' | A phone line, 300 to 3400 Hz |
'radio' | A handheld radio, narrow and slightly distorted |
'robot' | A ring-modulated robot voice |
Instead of a preset, pass an array of biquad stages, applied in order:
voice.setPeerFilter(boId, [
{ type: 'highpass', frequency: 200 },
{ type: 'peaking', frequency: 2500, Q: 1, gain: 6 },
]); | Stage field | Meaning |
|---|---|
type | 'lowpass', 'highpass', 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch' or 'allpass' |
frequency | Hz, clamped to 10 to 24000 |
Q | Optional quality factor |
gain | Optional dB, for peaking, lowshelf and highshelf, clamped to -40 to 40 |
An unknown preset or a stage without a valid type throws a TypeError. Pass micFilter to joinVoice to start with a microphone filter.
Noise reduction
Choose how the microphone is cleaned up before it is sent:
const voice = await joinVoice(room, { noiseReduction: 'rnnoise' }); noiseReduction | Behavior |
|---|---|
'browser' (default) | The browser’s built-in noise suppression |
'rnnoise' | The RNNoise model, which also removes keyboard, fan and traffic noise while the player talks |
'off' | No noise suppression |
Echo cancellation and automatic gain control stay on in every mode.
'rnnoise' downloads about 270 KB the first time a player joins with it, and only then. It needs
AudioWorklet and WebAssembly SIMD, and a page whose Content-Security-Policy allows blob: in script-src. Where it cannot run, the call uses 'browser' and logs a warning. voice.noiseReduction reports the mode that is running.
Echo cancellation with positional audio and filters
Positional audio and filters play through Web Audio. In Chromium-based browsers, the client sends that audio through a local WebRTC loopback so the browser’s echo cancellation still works for players on speakers. This adds a few tens of milliseconds of playback latency, only to calls that use positional audio or a peer filter. If the loopback cannot start, audio plays directly and the client logs a warning; recommend headphones in that case.
Usage
Voice is billed per participant-hour and counted by the minute. Four people in a two-hour call use eight participant-hours. Muted participants still count while they remain in the call. Voice traffic also counts toward data out.
A call holds up to 20 participants. Joining a full call rejects with a VoiceError whose err.code is E_VOICE_ROOM_FULL, and the player stays in the game room. A project also has a per-server cap on voice participants and on rooms with a call in progress; a join past either rejects with E_VOICE_PROJECT_FULL.
Audience (viewer) seats cannot join voice. joinVoice from a viewer rejects with E_VOICE_AUDIENCE.
Check the dashboard or irtio usage for usage, and pricing for allowances.