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.codeMeaning
E_VOICE_UNAVAILABLEThis project has no voice server. Hide the voice button.
E_VOICE_ROOM_FULLThe call has no free place.
E_VOICE_PROJECT_FULLThe project is at its cap on voice participants or on rooms with a call in progress.
E_VOICE_AUDIENCEThe player holds an audience (viewer) seat, which cannot join voice.
E_VOICE_MIC_DENIEDThe player or the page’s permissions refused the microphone. The browser’s NotAllowedError is on err.cause.
E_VOICE_NO_MICNo usable microphone was found.
E_VOICE_NO_MEDIAThe browser has no microphone API here, usually because the page is not served over HTTPS or localhost.
E_VOICE_UNSUPPORTEDThis browser cannot run voice chat.
E_VOICE_FAILEDThe voice server failed the request. Trying again later may work.

A failed join never affects the game connection.

Controls

MemberPurpose
voice.mute(true)Stop sending audio while continuing to listen
voice.mutedRead the current mute setting
voice.peersRead 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.micFilterRead the current microphone filter
voice.noiseReductionRead 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.

FieldMeaning
mutedThat peer’s own microphone mute, as last reported by the server
mutedLocallyYour local silencing of that peer on this machine
volumeYour local playback volume for that peer, 0 to 1
positionWhere setPeerPosition placed that peer, or null
filterThe 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.

EventFires when
peer-joinedA peer enters the call, with the peer id
peer-leftA peer leaves the call, with the peer id
peer-mutedA peer changes their own microphone mute, with { peerId, muted }
peers-changedAnything above changed, or you changed a local volume or mute
speakingA participant starts or stops making sound, with { peerId, speaking }
errorThe 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.

OptionDefaultMeaning
panningModel'HRTF''HRTF' for 3D sound on headphones, 'equalpower' for plain stereo panning
distanceModel'inverse''linear', 'inverse' or 'exponential' volume falloff
refDistance1Distance, in your units, at which falloff starts
maxDistance10000Distance at which falloff stops. With 'linear', the peer is silent from here
rolloffFactor1How 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);
PresetSound
'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 fieldMeaning
type'lowpass', 'highpass', 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch' or 'allpass'
frequencyHz, clamped to 10 to 24000
QOptional quality factor
gainOptional 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' });
noiseReductionBehavior
'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.