Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
Add immersive voice communication to your multiplayer games with spatial audio, push-to-talk, voice channels, and privacy controls powered by WebRTC.
Voice chat enhances multiplayer experiences by enabling real-time communication between players. Web Engine provides a comprehensive voice chat system built on WebRTC with support for spatial audio, push-to-talk, voice channels, and advanced audio processing.
3D positional audio that attenuates with distance for immersive communication.
Hold-to-speak controls with configurable keybindings for precise voice activation.
Separate voice channels for teams, proximity chat, and global communication.
Noise suppression, echo cancellation, and automatic gain control.
Mute, block, and volume controls for individual players.
Configure audio quality, codec, bitrate, and processing settings.
Voice chat uses WebRTC for peer-to-peer or server-relayed audio streaming. The setup involves requesting microphone permissions, creating audio streams, and establishing peer connections:
import { VoiceChatManager } from '@web-engine-dev/core/network';// Create voice chat managerconst voiceChat = new VoiceChatManager({spatialAudio: true, // Enable 3D positional audiomaxDistance: 50, // Voice audible within 50mrolloffFactor: 1.0, // Audio attenuation rateaudioConstraints: {echoCancellation: true,noiseSuppression: true,autoGainControl: true}});// Request microphone permissionasync function initializeVoice() {try {await voiceChat.requestMicrophonePermission();console.log('Microphone access granted');// Create local audio streamawait voiceChat.createLocalStream();console.log('Local audio stream created');// Connect to voice serverawait voiceChat.connect(network.getRoomId());console.log('Connected to voice chat');} catch (error) {console.error('Voice chat initialization failed:', error);showMicrophonePermissionError();}}// Call on game startinitializeVoice();
Browser Permissions
Voice chat requires microphone permissions. Always request permissions with clear user prompts explaining why voice chat is being activated. Browsers may block automatic microphone access without user interaction.
Voice chat establishes peer-to-peer connections using WebRTC. The signaling process is handled through the game server (Colyseus):
import { NetworkManager, PacketType } from '@web-engine-dev/core/network';class VoiceChatManager {private peerConnections = new Map<string, RTCPeerConnection>();private audioElements = new Map<string, HTMLAudioElement>();// Create peer connection for remote playerasync createPeerConnection(peerId: string): Promise<RTCPeerConnection> {const config: RTCConfiguration = {iceServers: [{ urls: 'stun:stun.l.google.com:19302' },{ urls: 'stun:stun1.l.google.com:19302' }]};const pc = new RTCPeerConnection(config);// Add local audio tracksconst localStream = this.localStream;if (localStream) {localStream.getTracks().forEach(track => {pc.addTrack(track, localStream);});}// Handle incoming audio trackspc.ontrack = (event) => {console.log('Received remote audio track from', peerId);this.handleRemoteTrack(peerId, event.streams[0]);};// Handle ICE candidatespc.onicecandidate = (event) => {if (event.candidate) {// Send ICE candidate to peer via game servernetwork.sendPacket({type: PacketType.ICE_CANDIDATE,senderId: network.getClientId(),timestamp: network.getServerTime(),targetPeerId: peerId,candidate: event.candidate});}};// Monitor connection statepc.onconnectionstatechange = () => {console.log(`Voice connection to ${peerId}: ${pc.connectionState}`);if (pc.connectionState === 'failed') {console.error('Voice connection failed, attempting ICE restart');pc.restartIce();}};this.peerConnections.set(peerId, pc);return pc;}// Create and send offerasync createOffer(peerId: string): Promise<void> {const pc = await this.createPeerConnection(peerId);const offer = await pc.createOffer({offerToReceiveAudio: true,offerToReceiveVideo: false});await pc.setLocalDescription(offer);// Send offer to peer via game servernetwork.sendPacket({type: PacketType.SDP_OFFER,senderId: network.getClientId(),timestamp: network.getServerTime(),targetPeerId: peerId,sdp: offer});}// Handle incoming offerasync handleOffer(peerId: string, offer: RTCSessionDescriptionInit): Promise<void> {const pc = await this.createPeerConnection(peerId);await pc.setRemoteDescription(offer);const answer = await pc.createAnswer();await pc.setLocalDescription(answer);// Send answer to peernetwork.sendPacket({type: PacketType.SDP_ANSWER,senderId: network.getClientId(),timestamp: network.getServerTime(),targetPeerId: peerId,sdp: answer});}// Handle incoming answerasync handleAnswer(peerId: string, answer: RTCSessionDescriptionInit): Promise<void> {const pc = this.peerConnections.get(peerId);if (pc) {await pc.setRemoteDescription(answer);}}// Handle ICE candidateasync handleIceCandidate(peerId: string, candidate: RTCIceCandidateInit): Promise<void> {const pc = this.peerConnections.get(peerId);if (pc) {await pc.addIceCandidate(candidate);}}}// Listen for WebRTC signaling messagesnetwork.on('sdpOffer', async (packet) => {await voiceChat.handleOffer(packet.senderId.toString(), packet.sdp);});network.on('sdpAnswer', async (packet) => {await voiceChat.handleAnswer(packet.senderId.toString(), packet.sdp);});network.on('iceCandidate', async (packet) => {await voiceChat.handleIceCandidate(packet.senderId.toString(), packet.candidate);});
Spatial voice audio creates immersive 3D positional audio where voice volume and direction change based on player positions:
import { SpatialAudioSystem } from '@web-engine-dev/core/audio';import { Transform } from '@web-engine-dev/core';// Create spatial audio systemconst spatialAudio = new SpatialAudioSystem(audioContext);// Configure distance model for voiceconst voiceDistanceModel = {type: 'linear' as const,refDistance: 1, // Full volume within 1mmaxDistance: 50, // Inaudible beyond 50mrolloffFactor: 1.0 // Linear falloff};// Set up spatial audio for remote playerfunction setupSpatialVoice(peerId: string, audioStream: MediaStream) {// Create audio elementconst audio = new Audio();audio.srcObject = audioStream;audio.play();// Create spatial audio sourceconst source = audioContext.createMediaStreamSource(audioStream);const panner = audioContext.createPanner();// Configure 3D audiopanner.panningModel = 'HRTF';panner.distanceModel = voiceDistanceModel.type;panner.refDistance = voiceDistanceModel.refDistance;panner.maxDistance = voiceDistanceModel.maxDistance;panner.rolloffFactor = voiceDistanceModel.rolloffFactor;// Connect audio graphsource.connect(panner);panner.connect(audioContext.destination);// Store for updatesspatialAudioSources.set(peerId, { audio, panner, source });}// Update spatial audio every framefunction updateSpatialVoice(world: IWorld, cameraEid: number) {// Update listener position from cameraspatialAudio.updateListener(camera);// Update each remote player's voice positionspatialAudioSources.forEach((audioSource, peerId) => {const eid = findEntityByPeerId(peerId);if (!eid) return;// Get player positionconst position = new Vector3(Transform.position[eid]![0],Transform.position[eid]![1],Transform.position[eid]![2]);// Update audio source positionconst panner = audioSource.panner;const time = audioContext.currentTime;if (panner.positionX) {panner.positionX.setValueAtTime(position.x, time);panner.positionY.setValueAtTime(position.y, time);panner.positionZ.setValueAtTime(position.z, time);} else {panner.setPosition(position.x, position.y, position.z);}});}// Call in render loopfunction onRender(world: IWorld, cameraEid: number) {updateSpatialVoice(world, cameraEid);}
Spatial Voice Distance
Tune maxDistance based on your game's scale. For close-quarters combat, use 20-30m. For open-world games, use 50-100m. This prevents voice chat from spanning the entire map while maintaining immersion.
class VoiceChatManager {private isPushToTalkEnabled = true;private isTalking = false;private pushToTalkKey = 'KeyV'; // V key// Enable/disable microphone based on PTT statesetMicrophoneEnabled(enabled: boolean) {if (!this.localStream) return;this.localStream.getAudioTracks().forEach(track => {track.enabled = enabled;});this.isTalking = enabled;// Visual feedbackif (enabled) {showTalkingIndicator();} else {hideTalkingIndicator();}}// Handle PTT key pressonKeyDown(event: KeyboardEvent) {if (event.code === this.pushToTalkKey && this.isPushToTalkEnabled) {if (!this.isTalking) {this.setMicrophoneEnabled(true);console.log('Push-to-talk: Started talking');}}}// Handle PTT key releaseonKeyUp(event: KeyboardEvent) {if (event.code === this.pushToTalkKey && this.isPushToTalkEnabled) {if (this.isTalking) {this.setMicrophoneEnabled(false);console.log('Push-to-talk: Stopped talking');}}}// Toggle PTT modesetPushToTalkEnabled(enabled: boolean) {this.isPushToTalkEnabled = enabled;if (!enabled) {// Always-on modethis.setMicrophoneEnabled(true);} else {// PTT mode (default off)this.setMicrophoneEnabled(false);}}// Configure PTT keysetPushToTalkKey(keyCode: string) {this.pushToTalkKey = keyCode;}}// Set up keyboard listenerswindow.addEventListener('keydown', (e) => voiceChat.onKeyDown(e));window.addEventListener('keyup', (e) => voiceChat.onKeyUp(e));// Settings UIfunction renderVoiceSettings() {return (<div><label><inputtype="checkbox"checked={voiceChat.isPushToTalkEnabled}onChange={(e) => voiceChat.setPushToTalkEnabled(e.target.checked)}/>Enable Push-to-Talk</label><label>PTT Key:<inputtype="text"value={voiceChat.pushToTalkKey}onChange={(e) => voiceChat.setPushToTalkKey(e.target.value)}/></label></div>);}
Voice channels allow segregating voice communication by teams, proximity, or broadcast:
| Channel Type | Description | Use Case |
|---|---|---|
| Global | All players in room can hear | Lobby chat, announcements |
| Team | Only team members can hear | Team coordination, strategy |
| Proximity | Players within distance can hear | Spatial chat, local interactions |
| Direct | One-to-one private communication | Private messages, whispers |
enum VoiceChannel {GLOBAL = 'global',TEAM = 'team',PROXIMITY = 'proximity',DIRECT = 'direct'}class VoiceChannelManager {private currentChannel: VoiceChannel = VoiceChannel.PROXIMITY;private teamId: string | null = null;// Check if we can hear player in current channelcanHearPlayer(peerId: string, playerData: PlayerData): boolean {switch (this.currentChannel) {case VoiceChannel.GLOBAL:// Hear all playersreturn true;case VoiceChannel.TEAM:// Only hear teammatesreturn playerData.teamId === this.teamId;case VoiceChannel.PROXIMITY:// Only hear nearby players (handled by spatial audio)const distance = getDistanceToPlayer(peerId);return distance <= spatialAudioConfig.maxDistance;case VoiceChannel.DIRECT:// Only hear specific player (1v1 chat)return peerId === this.directChatTarget;default:return false;}}// Switch voice channelsetChannel(channel: VoiceChannel) {console.log(`Switching to ${channel} voice channel`);this.currentChannel = channel;// Update audio routingthis.updateAudioConnections();// UI feedbackshowChannelIndicator(channel);}// Update which peers we're connected toupdateAudioConnections() {const allPeers = getAllConnectedPeers();allPeers.forEach(peerId => {const playerData = getPlayerData(peerId);const shouldHear = this.canHearPlayer(peerId, playerData);const audioSource = spatialAudioSources.get(peerId);if (audioSource) {// Mute/unmute based on channel rulesaudioSource.audio.muted = !shouldHear;}});}}// UI for channel switchingfunction renderChannelSelector() {return (<div className="voice-channels"><button onClick={() => channelManager.setChannel(VoiceChannel.GLOBAL)}>Global</button><button onClick={() => channelManager.setChannel(VoiceChannel.TEAM)}>Team</button><button onClick={() => channelManager.setChannel(VoiceChannel.PROXIMITY)}>Proximity</button></div>);}// Hotkey for quick channel switchingwindow.addEventListener('keydown', (e) => {if (e.code === 'Digit1') channelManager.setChannel(VoiceChannel.GLOBAL);if (e.code === 'Digit2') channelManager.setChannel(VoiceChannel.TEAM);if (e.code === 'Digit3') channelManager.setChannel(VoiceChannel.PROXIMITY);});
// Configure audio constraints for high-quality voiceconst audioConstraints: MediaTrackConstraints = {// Noise reductionnoiseSuppression: true,echoCancellation: true,autoGainControl: true,// Quality settingssampleRate: 48000, // 48kHz sample ratechannelCount: 1, // Mono for voicelatency: 0.01, // Low latency (10ms)// Optional: advanced constraintssampleSize: 16, // 16-bit audio};// Create high-quality audio streamasync function createHighQualityStream() {const stream = await navigator.mediaDevices.getUserMedia({audio: audioConstraints,video: false});// Apply additional processingconst audioContext = new AudioContext({ sampleRate: 48000 });const source = audioContext.createMediaStreamSource(stream);// Optional: Add noise gate to reduce background noiseconst noiseGate = createNoiseGate(audioContext, {threshold: -50, // dB thresholdattack: 0.01, // Fast attackrelease: 0.1 // Moderate release});source.connect(noiseGate);noiseGate.connect(audioContext.destination);return stream;}// Voice Activity Detection (VAD)class VoiceActivityDetector {private analyser: AnalyserNode;private dataArray: Uint8Array;private threshold = 30; // Adjust based on environmentconstructor(audioContext: AudioContext, stream: MediaStream) {this.analyser = audioContext.createAnalyser();this.analyser.fftSize = 512;this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);const source = audioContext.createMediaStreamSource(stream);source.connect(this.analyser);}// Check if user is speakingisVoiceActive(): boolean {this.analyser.getByteFrequencyData(this.dataArray);// Calculate average volumeconst average = this.dataArray.reduce((a, b) => a + b) / this.dataArray.length;return average > this.threshold;}// Auto-mute when not speaking (for PTT alternative)update() {const isActive = this.isVoiceActive();if (isActive) {showTalkingIndicator();} else {hideTalkingIndicator();}return isActive;}}// Use VAD for voice-activated transmissionconst vad = new VoiceActivityDetector(audioContext, localStream);setInterval(() => {const isSpeaking = vad.update();// Auto-enable mic when speaking detectedif (voiceActivatedMode && isSpeaking !== voiceChat.isTalking) {voiceChat.setMicrophoneEnabled(isSpeaking);}}, 100); // Check every 100ms
class PlayerVoiceControls {private mutedPlayers = new Set<string>();private blockedPlayers = new Set<string>();private volumeLevels = new Map<string, number>(); // 0-1// Mute specific playermutePlayer(peerId: string) {this.mutedPlayers.add(peerId);const audioSource = spatialAudioSources.get(peerId);if (audioSource) {audioSource.audio.muted = true;}console.log(`Muted player: ${peerId}`);}// Unmute specific playerunmutePlayer(peerId: string) {this.mutedPlayers.delete(peerId);const audioSource = spatialAudioSources.get(peerId);if (audioSource && !this.blockedPlayers.has(peerId)) {audioSource.audio.muted = false;}console.log(`Unmuted player: ${peerId}`);}// Block player (prevents all communication)blockPlayer(peerId: string) {this.blockedPlayers.add(peerId);this.mutedPlayers.add(peerId);// Disconnect voice connectionconst pc = voiceChat.peerConnections.get(peerId);if (pc) {pc.close();voiceChat.peerConnections.delete(peerId);}// Remove audio sourceconst audioSource = spatialAudioSources.get(peerId);if (audioSource) {audioSource.audio.pause();audioSource.source.disconnect();spatialAudioSources.delete(peerId);}console.log(`Blocked player: ${peerId}`);}// Set individual player volumesetPlayerVolume(peerId: string, volume: number) {// Clamp to 0-1 rangevolume = Math.max(0, Math.min(1, volume));this.volumeLevels.set(peerId, volume);const audioSource = spatialAudioSources.get(peerId);if (audioSource) {audioSource.audio.volume = volume;}console.log(`Set ${peerId} volume to ${(volume * 100).toFixed(0)}%`);}// Get player volumegetPlayerVolume(peerId: string): number {return this.volumeLevels.get(peerId) ?? 1.0;}// Check if player is mutedisPlayerMuted(peerId: string): boolean {return this.mutedPlayers.has(peerId);}// Mute all playersmuteAll() {spatialAudioSources.forEach((_, peerId) => {this.mutePlayer(peerId);});}// Unmute all playersunmuteAll() {const muted = Array.from(this.mutedPlayers);muted.forEach(peerId => this.unmutePlayer(peerId));}}// UI for player voice controlsfunction renderPlayerVoiceControl(peerId: string, playerName: string) {const controls = new PlayerVoiceControls();const isMuted = controls.isPlayerMuted(peerId);const volume = controls.getPlayerVolume(peerId);return (<div className="player-voice-control"><span>{playerName}</span><button onClick={() => {if (isMuted) {controls.unmutePlayer(peerId);} else {controls.mutePlayer(peerId);}}}>{isMuted ? 'Unmute' : 'Mute'}</button><inputtype="range"min="0"max="100"value={volume * 100}onChange={(e) => controls.setPlayerVolume(peerId, e.target.value / 100)}/><button onClick={() => controls.blockPlayer(peerId)}>Block</button></div>);}
| Issue | Cause | Solution |
|---|---|---|
| No audio from remote player | Audio element not playing | Call audio.play() and check autoplay policies |
| Echo/feedback | Echo cancellation disabled | Enable echoCancellation in audio constraints |
| Connection fails | STUN/TURN server issues | Add TURN servers for NAT traversal |
| High latency | Server relay mode | Use peer-to-peer connections when possible |
| Distorted audio | Bitrate too low | Increase audio bitrate in codec settings |
| Spatial audio not working | Listener/source not updated | Update positions every frame |