Skip to content

WebRTC - Voice Demo

In this chapter, we’ll build a client application enabling two users on separate devices to communicate using WebRTC audio streams. Our application will feature two main views: one for login and another for initiating an audio call.

For context, the original tutorial used an image here depicting a simple login page with a username input and a ‘Sign in’ button, and a call page showing ‘Local audio’ and ‘Remote audio’ elements, an input for ‘username to call’, a ‘Call’ button, and a ‘Hang Up’ button. We will describe these UI elements in the HTML section.

The two views will be managed by toggling the display of div elements. User interactions will be handled through JavaScript event listeners.

To establish a WebRTC connection, clients need a way to exchange metadata (like SDP offers/answers and ICE candidates) before a direct peer-to-peer connection is formed. This process is called signaling. We’ll use WebSockets for this, providing a bidirectional communication channel between clients and our Node.js signaling server. Ensure you have Node.js installed and the ws library (npm install ws).

// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 9090 });
// Store connected users
const users = {};
console.log('Signaling server started on ws://localhost:9090');
wss.on('connection', (connection) => {
console.log('User connected');
connection.on('message', (message) => {
let data;
try {
data = JSON.parse(message);
} catch (e) {
console.log('Invalid JSON received:', message);
data = {};
}
console.log('Received message:', data);
switch (data.type) {
case 'login':
handleLogin(connection, data);
break;
case 'offer':
handleOffer(connection, data);
break;
case 'answer':
handleAnswer(connection, data);
break;
case 'candidate':
handleCandidate(connection, data);
break;
case 'leave':
handleLeave(connection, data);
break;
default:
sendTo(connection, {
type: 'error',
message: `Command not found: ${data.type}`
});
break;
}
});
connection.on('close', () => {
handleDisconnect(connection);
});
connection.on('error', (error) => {
console.error('Connection error:', error);
handleDisconnect(connection); // Treat error as a disconnect
});
// Send a welcome message (optional)
// sendTo(connection, { type: 'info', message: 'Connected to signaling server' });
});
function sendTo(conn, message) {
try {
conn.send(JSON.stringify(message));
} catch (error) {
console.error('Error sending message:', error, 'to user:', conn.name);
}
}
function handleLogin(connection, data) {
if (users[data.name]) {
sendTo(connection, { type: 'login', success: false, message: 'Username is already taken.' });
} else {
users[data.name] = connection;
connection.name = data.name;
console.log(`User logged in: ${data.name}`);
sendTo(connection, { type: 'login', success: true });
}
}
function handleOffer(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
console.log(`Sending offer from ${connection.name} to ${data.name}`);
connection.otherName = data.name; // Track who this user is trying to connect to
sendTo(targetUser, {
type: 'offer',
offer: data.offer,
name: connection.name
});
} else {
console.log(`User ${data.name} not found for offer.`);
sendTo(connection, { type: 'error', message: `User ${data.name} not found.` });
}
}
function handleAnswer(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
console.log(`Sending answer from ${connection.name} to ${data.name}`);
connection.otherName = data.name;
sendTo(targetUser, {
type: 'answer',
answer: data.answer
});
} else {
console.log(`User ${data.name} not found for answer.`);
}
}
function handleCandidate(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
// console.log(`Sending ICE candidate from ${connection.name} to ${data.name}`); // Can be very verbose
sendTo(targetUser, {
type: 'candidate',
candidate: data.candidate
});
} else {
// console.log(`User ${data.name} not found for ICE candidate.`); // Can be very verbose
}
}
function handleLeave(connection, data) {
const targetUser = users[data.name]; // User to notify
console.log(`User ${connection.name} is leaving call with ${data.name}`);
if (targetUser) {
targetUser.otherName = null;
sendTo(targetUser, { type: 'leave' });
}
// Clean up for the current user as well if they initiated the leave
if (connection.otherName === data.name) {
connection.otherName = null;
}
}
function handleDisconnect(connection) {
console.log(`User ${connection.name || 'unknown'} disconnected`);
if (connection.name && users[connection.name]) {
delete users[connection.name];
}
// Notify the other party if they were in a call
if (connection.otherName) {
const targetUser = users[connection.otherName];
if (targetUser) {
console.log(`Notifying ${connection.otherName} about ${connection.name}'s disconnect.`);
targetUser.otherName = null;
sendTo(targetUser, { type: 'leave' });
}
}
}

This server listens for WebSocket connections on port 9090. It handles different message types for login, WebRTC signaling (offer, answer, ICE candidates), and disconnecting (leave).

  • It stores connected users in a simple users object, keyed by username.
  • Login checks for unique usernames.
  • Offer, answer, and candidate messages are relayed to the specified target user.
  • The leave message and disconnections notify the other party to clean up the WebRTC connection.

The sendTo function is a helper for sending JSON messages to a specific connection. The handleDisconnect function cleans up user state when a connection closes or an error occurs.

To test this application, you’ll typically open two browser tabs (or two different browsers/devices) and attempt an audio call between them.

First, create an index.html file. We’ll use a CDN for Bootstrap for styling to keep things simple. Create a folder (e.g., webrtc-voice-app) and place index.html and client.js (created next) inside it.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Voice Demo</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f0f0f0; padding: 20px; }
.container { max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
#loginPage, #callPage { margin-top: 20px; }
audio { width: 100%; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<div id="loginPage">
<h2 class="text-center">WebRTC Voice Demo</h2>
<p class="text-center">Please sign in</p>
<div class="form-group">
<input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required>
</div>
<button id="loginBtn" class="btn btn-primary btn-block">Sign In</button>
</div>
<div id="callPage" style="display: none;">
<h3 class="text-center">Hi, <span id="currentUser"></span>!</h3>
<div class="row">
<div class="col-md-6">
<h4>Local Audio</h4>
<audio id="localAudio" controls autoplay muted></audio> <!-- muted to prevent echo -->
</div>
<div class="col-md-6">
<h4>Remote Audio</h4>
<audio id="remoteAudio" controls autoplay></audio>
</div>
</div>
<hr>
<div class="form-group">
<input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to call">
</div>
<button id="callBtn" class="btn btn-success">Call</button>
<button id="hangUpBtn" class="btn btn-danger">Hang Up</button>
</div>
</div>
<script src="client.js"></script>
</body>
</html>

This HTML sets up two main div sections: loginPage and callPage. It includes input fields for username, target username, and buttons for actions. Two <audio> elements are provided for local and remote audio streams. We link to client.js, which will contain our WebRTC logic.

Now, create client.js in the same folder:

// client.js
let name;
let connectedUser;
let localStream;
let peerConnection;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM Elements
const loginPage = document.querySelector('#loginPage');
const usernameInput = document.querySelector('#usernameInput');
const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');
const currentUserDisplay = document.querySelector('#currentUser');
const callToUsernameInput = document.querySelector('#callToUsernameInput');
const callBtn = document.querySelector('#callBtn');
const hangUpBtn = document.querySelector('#hangUpBtn');
const localAudio = document.querySelector('#localAudio');
const remoteAudio = document.querySelector('#remoteAudio');
// ICE servers configuration
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// You can add more STUN/TURN servers here if needed
// { urls: 'turn:your.turn.server:port', username: 'user', credential: 'password' }
]
};
connection.onopen = () => {
console.log('Connected to signaling server');
};
connection.onmessage = async (msg) => {
console.log('Got message', msg.data);
const data = JSON.parse(msg.data);
switch (data.type) {
case 'login':
handleLogin(data.success, data.message);
break;
case 'offer':
await handleOffer(data.offer, data.name);
break;
case 'answer':
await handleAnswer(data.answer);
break;
case 'candidate':
await handleCandidate(data.candidate);
break;
case 'leave':
handleLeave();
break;
case 'error':
alert(`Error: ${data.message}`);
break;
default:
break;
}
};
connection.onerror = (err) => {
console.error('WebSocket error:', err);
alert('Error connecting to signaling server. Please ensure it is running and accessible.');
};
function send(message) {
if (connectedUser) {
message.name = connectedUser; // Target user for signaling messages
}
try {
connection.send(JSON.stringify(message));
} catch (error) {
console.error('Error sending WebSocket message:', error);
}
}
// UI Event Listeners
loginBtn.addEventListener('click', () => {
name = usernameInput.value;
if (name.length > 0) {
send({ type: 'login', name: name });
} else {
alert('Please enter a username.');
}
});
async function handleLogin(success, message) {
if (success === false) {
alert(`Login failed: ${message || 'Try a different username.'}`);
} else {
loginPage.style.display = 'none';
callPage.style.display = 'block';
currentUserDisplay.textContent = name;
try {
// Get local audio stream
localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
localAudio.srcObject = localStream;
// Initialize RTCPeerConnection
initializePeerConnection();
} catch (error) {
console.error('Error accessing media devices or initializing PeerConnection:', error);
alert('Error setting up media: ' + error.message);
// Revert to login page or handle error appropriately
loginPage.style.display = 'block';
callPage.style.display = 'none';
}
}
}
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// Add local stream tracks to the peer connection
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
// Handle incoming remote tracks
peerConnection.ontrack = (event) => {
console.log('Remote track received');
if (event.streams && event.streams[0]) {
remoteAudio.srcObject = event.streams[0];
} else {
// Fallback for older browsers or specific track event scenarios
let inboundStream = new MediaStream();
inboundStream.addTrack(event.track);
remoteAudio.srcObject = inboundStream;
}
};
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({ type: 'candidate', candidate: event.candidate });
}
};
// Optional: Log connection state changes
peerConnection.oniceconnectionstatechange = () => {
console.log(`ICE connection state: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'failed' ||
peerConnection.iceConnectionState === 'disconnected' ||
peerConnection.iceConnectionState === 'closed') {
// Potentially handle connection failure here, e.g., alert user or attempt reconnect
// For this demo, we mostly rely on the 'leave' signal or manual hang-up
}
};
}
callBtn.addEventListener('click', async () => {
const callToUsername = callToUsernameInput.value;
if (callToUsername.length === 0) {
alert('Please enter a username to call.');
return;
}
if (callToUsername === name) {
alert('You cannot call yourself.');
return;
}
connectedUser = callToUsername;
console.log(`Initiating call to ${connectedUser}`);
if (!peerConnection) initializePeerConnection(); // Ensure peerConnection is initialized
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send({ type: 'offer', offer: offer });
} catch (error) {
console.error('Error creating offer:', error);
alert('Error creating call offer: ' + error.message);
}
});
async function handleOffer(offer, fromName) {
if (!peerConnection) initializePeerConnection(); // Ensure PC is ready for remote offer
connectedUser = fromName;
console.log(`Received offer from ${connectedUser}`);
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send({ type: 'answer', answer: answer });
} catch (error) {
console.error('Error handling offer:', error);
alert('Error handling incoming call: ' + error.message);
}
}
async function handleAnswer(answer) {
console.log('Received answer');
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async function handleCandidate(candidate) {
try {
if (candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
hangUpBtn.addEventListener('click', () => {
send({ type: 'leave' });
handleLeave();
});
function handleLeave() {
console.log('Call ended or other user left.');
connectedUser = null;
if (remoteAudio) remoteAudio.srcObject = null;
if (peerConnection) {
peerConnection.close();
peerConnection.onicecandidate = null;
peerConnection.ontrack = null;
peerConnection.oniceconnectionstatechange = null;
// It's good practice to re-initialize peerConnection if a new call is to be made
// For simplicity, we can re-init it here or before next call attempt
// initializePeerConnection(); // Or nullify and re-create on next call
peerConnection = null; // Nullify to be re-created for the next call
}
callToUsernameInput.value = ''; // Clear the input field
// Optionally, re-initialize peerConnection for a new call
// if (localStream) initializePeerConnection(); // If localStream is still active
}
// Clean up on window close
window.addEventListener('beforeunload', () => {
if (name) { // If logged in
send({ type: 'leave' }); // Inform other user
// Additional cleanup if connection is still active
if (connection && connection.readyState === WebSocket.OPEN) {
// Send a specific disconnect message if your server handles it
// For this example, 'leave' covers it, or server handles 'close' event.
}
}
if (peerConnection) {
peerConnection.close();
}
if (connection) {
connection.close();
}
});

Key updates in client.js:

  • Uses navigator.mediaDevices.getUserMedia (Promise-based) instead of the deprecated callback-based navigator.getUserMedia.
  • Uses localAudio.srcObject = stream instead of URL.createObjectURL.
  • Uses the standard RTCPeerConnection constructor.
  • Replaces addStream with addTrack (iterating over localStream.getTracks()).
  • Replaces onaddstream with ontrack for handling remote media.
  • Uses async/await for WebRTC operations like createOffer, setLocalDescription, etc., for cleaner asynchronous code.
  • Includes basic error handling and console logging for better debugging.
  • The initializePeerConnection function encapsulates the setup of RTCPeerConnection and its event handlers. This function is called after successful login and media acquisition.
  • The handleLeave function now more thoroughly cleans up the peerConnection object, setting its event handlers to null and closing it. It’s important to either re-initialize or nullify peerConnection to ensure a clean state for subsequent calls.

To run the demo:

  1. Save server.js, index.html, and client.js.
  2. Open a terminal, navigate to the folder containing server.js, and run node server.js.
  3. Open index.html in two different browser tabs (or different browsers/devices on the same network if your STUN/TURN setup allows).
  4. Log in with unique usernames in each tab.
  5. Enter the other tab’s username in the ‘Username to call’ field and click ‘Call’.

You should be able to establish an audio call. The browser’s developer console will show logs from both the client and server, which is helpful for troubleshooting. For example, the original tutorial showed an image of the console output confirming connection to the signaling server. This is now achieved via console.log.

An image in the original tutorial showed the local audio stream playing after login. This is represented by the localAudio element now playing your microphone’s input.

Further images depicted successful login on two tabs (e.g., ‘userA’ and ‘userB’) and then an active call between them, followed by hanging up. This functionality is what the updated code aims to replicate with modern APIs.

This example focuses on the core WebRTC audio chat functionality. For production applications, consider more robust error handling, UI/UX improvements, and potentially a TURN server for dealing with restrictive NATs/firewalls. More information on STUN/TURN can be found in chapters discussing NAT traversal.