Lesson 09-WebRTC One-to-One Real-Time Communication Implementation

WebRTC (Web Real-Time Communication) technology enables direct real-time audio and video communication between browsers without requiring plugins or intermediate servers (except for signaling servers). This document provides a detailed guide on implementing one-to-one real-time communication using WebRTC, including a complete technical implementation, code examples, and key considerations.

One-to-One Communication Architecture Design

Basic Communication Flow

The basic flow for one-to-one WebRTC communication is as follows:

  1. Signaling Exchange Phase:
    • Both parties exchange SDP (Session Description Protocol) information
    • Exchange ICE (Interactive Connectivity Establishment) candidate addresses
  2. Connection Establishment Phase:
    • Establish a direct connection using ICE candidate addresses
    • Complete DTLS-SRTP handshake for encryption
  3. Media Transmission Phase:
    • Directly transmit audio and video streams
    • Optional data channel transmission

Communication Flow Diagram:

[User A] ---Signaling Server---> [User B]
    |                           |
    |--SDP Exchange-->         |--SDP Exchange-->
    |                           |
    |--ICE Candidate Exchange--> |--ICE Candidate Exchange-->
    |                           |
[Direct Media Stream] <--------> [Direct Media Stream]

Role Definitions

In one-to-one communication, there are typically two roles:

  1. Initiator:
    • Creates the offer SDP
    • Initiates connection establishment
  2. Responder:
    • Receives the offer SDP
    • Creates the answer SDP

In practical applications, these roles are dynamic, depending on which party initiates the call request.

Complete Code Implementation

HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC One-to-One Video Call</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        .video-container {
            display: flex;
            flex-wrap: wrap;
            gap: 20px;
            margin-bottom: 20px;
        }
        video {
            width: 320px;
            height: 240px;
            background-color: #000;
            border: 1px solid #ccc;
        }
        .controls {
            margin-bottom: 20px;
        }
        button {
            padding: 8px 16px;
            margin-right: 10px;
            cursor: pointer;
        }
        #roomId {
            padding: 8px;
            width: 200px;
        }
        #status {
            margin-top: 10px;
            padding: 5px;
            border-radius: 3px;
        }
        .connected {
            background-color: #dff0d8;
            color: #3c763d;
        }
        .disconnected {
            background-color: #f2dede;
            color: #a94442;
        }
    </style>
</head>
<body>
    <h1>WebRTC One-to-One Video Call</h1>

    <div class="controls">
        <input type="text" id="roomId" placeholder="Enter Room ID">
        <button id="joinBtn">Join Room</button>
    </div>

    <div class="video-container">
        <div>
            <h3>Local Video</h3>
            <video id="localVideo" autoplay playsinline muted></video>
        </div>
        <div>
            <h3>Remote Video</h3>
            <video id="remoteVideo" autoplay playsinline></video>
        </div>
    </div>

    <div class="controls">
        <button id="startBtn" disabled>Start Call</button>
        <button id="hangupBtn" disabled>Hang Up</button>
    </div>

    <div id="status" class="disconnected">Disconnected</div>

    <script src="app.js"></script>
</body>
</html>

JavaScript Implementation (app.js)

// Global variables
let localStream;
let peerConnection;
let roomId;
let isInitiator = false;
let signalingChannel;
let remoteUserId = null;

// DOM elements
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const roomIdInput = document.getElementById('roomId');
const joinBtn = document.getElementById('joinBtn');
const startBtn = document.getElementById('startBtn');
const hangupBtn = document.getElementById('hangupBtn');
const statusDiv = document.getElementById('status');

// Join room button click event
joinBtn.addEventListener('click', () => {
    roomId = roomIdInput.value.trim();
    if (!roomId) {
        alert('Please enter a room ID');
        return;
    }

    // Create signaling channel (simulated with WebSocket)
    signalingChannel = createSignalingChannel();

    // Enable start button
    startBtn.disabled = false;
});

// Start call button click event
startBtn.addEventListener('click', async () => {
    try {
        // Get local media stream
        localStream = await navigator.mediaDevices.getUserMedia({
            video: true,
            audio: true
        });
        localVideo.srcObject = localStream;

        // Create RTCPeerConnection
        createPeerConnection();

        // Add local stream to connection
        localStream.getTracks().forEach(track => {
            peerConnection.addTrack(track, localStream);
        });

        // If initiator, create offer
        isInitiator = true;
        const offer = await peerConnection.createOffer();
        await peerConnection.setLocalDescription(offer);

        // Send offer to peer via signaling channel
        signalingChannel.send({
            type: 'offer',
            sdp: peerConnection.localDescription,
            roomId: roomId,
            from: 'user_' + Math.random().toString(36).substr(2, 9) // Generate temporary user ID
        });

        startBtn.disabled = true;
        hangupBtn.disabled = false;
        updateStatus('Call request initiated', 'connected');
    } catch (err) {
        console.error('Failed to start call:', err);
        alert('Failed to get media stream: ' + err.message);
    }
});

// Hang up button click event
hangupBtn.addEventListener('click', () => {
    if (peerConnection) {
        peerConnection.close();
        peerConnection = null;
    }

    if (localStream) {
        localStream.getTracks().forEach(track => track.stop());
        localStream = null;
        localVideo.srcObject = null;
        remoteVideo.srcObject = null;
    }

    startBtn.disabled = false;
    hangupBtn.disabled = true;
    updateStatus('Call ended', 'disconnected');
    remoteUserId = null;
});

// Create signaling channel (simulated WebSocket)
function createSignalingChannel() {
    // In production, this should create a WebSocket connection
    // Here, we simulate a signaling channel with an object
    return {
        send: function(data) {
            console.log('Sending signaling:', data);

            // Simulate network delay
            setTimeout(() => {
                // Simulate receiver processing signaling
                if (data.type === 'offer') {
                    // Simulate receiver creating answer
                    setTimeout(() => {
                        const simulatedAnswer = {
                            type: 'answer',
                            sdp: { /* Simulated SDP */ },
                            roomId: data.roomId,
                            from: 'user_' + Math.random().toString(36).substr(2, 9)
                        };
                        onSignalingMessage(simulatedAnswer);

                        // Simulate ICE candidate exchange
                        setTimeout(() => {
                            const simulatedCandidate = {
                                type: 'ice-candidate',
                                candidate: { /* Simulated ICE candidate */ },
                                roomId: data.roomId,
                                from: 'user_' + Math.random().toString(36).substr(2, 9)
                            };
                            onSignalingMessage(simulatedCandidate);
                        }, 100);
                    }, 100);
                } else if (data.type === 'ice-candidate') {
                    // Simulate receiving ICE candidate
                    setTimeout(() => {
                        onSignalingMessage(data);
                    }, 100);
                }
            }, 100);
        },
        onmessage: function(message) {
            // In production, this would receive messages from WebSocket
            // Here, we directly call the handler
            onSignalingMessage(message);
        }
    };
}

// Handle signaling messages
async function onSignalingMessage(message) {
    console.log('Received signaling message:', message);

    if (message.roomId !== roomId) {
        // Ignore messages not for the current room
        return;
    }

    if (message.from === remoteUserId) {
        // Messages from the remote user
        switch (message.type) {
            case 'offer':
                // If responder
                if (!isInitiator) {
                    // Create RTCPeerConnection
                    createPeerConnection();

                    // Set remote description
                    await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));

                    // Create answer
                    const answer = await peerConnection.createAnswer();
                    await peerConnection.setLocalDescription(answer);

                    // Send answer
                    signalingChannel.send({
                        type: 'answer',
                        sdp: peerConnection.localDescription,
                        roomId: roomId,
                        from: 'user_' + Math.random().toString(36).substr(2, 9)
                    });

                    remoteUserId = message.from;
                    updateStatus('Call request received', 'connected');
                }
                break;

            case 'answer':
                // If initiator
                if (isInitiator) {
                    // Set remote description
                    await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
                }
                break;

            case 'ice-candidate':
                // Add ICE candidate
                if (peerConnection) {
                    await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
                }
                break;
        }
    } else {
        // Messages from other users (possible in multi-room scenarios)
        console.log('Ignoring message from other user');
    }
}

// Create RTCPeerConnection
function createPeerConnection() {
    // Configure ICE servers (using Google's public STUN server)
    const configuration = {
        iceServers: [
            { urls: 'stun:stun.l.google.com:19302' }
            // In production, you may need to add TURN servers
            // { urls: 'turn:your-turn-server.com', username: 'user', credential: 'pass' }
        ]
    };

    peerConnection = new RTCPeerConnection(configuration);

    // Listen for ICE candidates
    peerConnection.onicecandidate = event => {
        if (event.candidate) {
            signalingChannel.send({
                type: 'ice-candidate',
                candidate: event.candidate,
                roomId: roomId,
                from: 'user_' + Math.random().toString(36).substr(2, 9)
            });
        }
    };

    // Listen for remote stream
    peerConnection.ontrack = event => {
        remoteVideo.srcObject = event.streams[0];
    };

    // Listen for ICE connection state changes
    peerConnection.oniceconnectionstatechange = () => {
        console.log('ICE connection state:', peerConnection.iceConnectionState);

        switch (peerConnection.iceConnectionState) {
            case 'connected':
                console.log('Connection established');
                updateStatus('Call connected', 'connected');
                break;
            case 'disconnected':
                console.log('Connection disconnected (may recover)');
                updateStatus('Connection disconnected (may recover)', 'disconnected');
                break;
            case 'failed':
                console.log('Connection failed (needs restart)');
                updateStatus('Connection failed', 'disconnected');
                // Add reconnection logic here if needed
                break;
            case 'closed':
                console.log('Connection closed');
                updateStatus('Connection closed', 'disconnected');
                break;
        }
    };

    // Listen for errors
    peerConnection.onerror = error => {
        console.error('RTCPeerConnection error:', error);
        updateStatus('Connection error', 'disconnected');
    };
}

// Update status display
function updateStatus(text, className) {
    statusDiv.textContent = text;
    statusDiv.className = className;
}

// Get media stream (example)
async function getMediaStream() {
    try {
        localStream = await navigator.mediaDevices.getUserMedia({
            video: true,
            audio: true
        });
        localVideo.srcObject = localStream;
        return localStream;
    } catch (err) {
        console.error('Failed to get media stream:', err);
        throw err;
    }
}

Key Technical Points Analysis

SDP Exchange Mechanism

The Session Description Protocol (SDP) is used in WebRTC to describe media session parameters:

  1. Offer/Answer Model:
    • Initiator creates offer SDP
    • Responder creates answer SDP
    • Both exchange SDP via signaling server
  2. SDP Content:
    • Media types (video/audio)
    • Codec preferences
    • Network transport parameters
    • ICE candidate information

SDP Exchange Flow:

  1. Initiator calls createOffer() to generate offer SDP
  2. Initiator calls setLocalDescription(offer) to set local description
  3. Initiator sends offer to responder via signaling server
  4. Responder receives offer and calls setRemoteDescription(offer) to set remote description
  5. Responder calls createAnswer() to generate answer SDP
  6. Responder calls setLocalDescription(answer) to set local description
  7. Responder sends answer to initiator via signaling server
  8. Initiator receives answer and calls setRemoteDescription(answer) to set remote description

ICE Candidate Collection and Exchange

Interactive Connectivity Establishment (ICE) protocol is used to establish direct connections in complex network environments:

  1. Candidate Types:
    • Host candidate (local IP address)
    • Reflexive candidate (public IP from STUN server)
    • Relay candidate (TURN server relay)
  2. Collection Process:
    • Local candidates (immediately available)
    • STUN candidates (require network requests)
    • TURN candidates (require network requests)
  3. Exchange Process:
    • Both parties exchange candidates via signaling server
    • Perform connectivity checks
    • Select optimal path

ICE State Transitions:

  • new: Initial state
  • checking: Checking candidates
  • connected: Connection established
  • completed: All candidates checked
  • failed: Connection failed
  • disconnected: Connection disrupted
  • closed: Connection closed

DTLS-SRTP Encryption Mechanism

WebRTC uses a DTLS-SRTP combination for end-to-end encryption:

  1. DTLS (Datagram Transport Layer Security):
    • Handles handshake and key exchange
    • Provides transport-layer security
  2. SRTP (Secure Real-time Transport Protocol):
    • Encrypts actual media streams
    • Ensures media stream security

Encryption Flow:

  1. Negotiate encryption parameters via DTLS handshake
  2. Derive media encryption keys
  3. Encrypt media streams using SRTP
  4. Encrypt data channels using DTLS

Practical Deployment Considerations

Signaling Server Implementation

In production, a reliable signaling server is required:

  1. WebSocket Server:
    • Node.js with ws library
    • Socket.io
    • Other WebSocket implementations
  2. Functional Requirements:
    • Message routing (ensure messages reach correct users)
    • Room management
    • Connection state monitoring

Simple Node.js Signaling Server Example:

const WebSocket = require('ws');
const http = require('http');

const server = http.createServer();
const wss = new WebSocket.Server({ server });

// Room management
const rooms = {};

wss.on('connection', (ws) => {
  // Assign temporary ID on user connection
  const userId = 'user_' + Math.random().toString(36).substr(2, 9);
  ws.userId = userId;

  console.log(`User ${userId} connected`);

  // Message handling
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);

      // Handle different message types
      switch (data.type) {
        case 'join':
          // Join room
          if (!rooms[data.roomId]) {
            rooms[data.roomId] = [];
          }
          rooms[data.roomId].push(ws);
          ws.roomId = data.roomId;
          console.log(`User ${userId} joined room ${data.roomId}`);
          break;

        case 'offer':
        case 'answer':
        case 'ice-candidate':
          // Forward signaling messages to other users in the room
          if (ws.roomId && rooms[ws.roomId]) {
            rooms[ws.roomId].forEach(client => {
              if (client !== ws && client.readyState === WebSocket.OPEN) {
                client.send(message);
              }
            });
          }
          break;
      }
    } catch (err) {
      console.error('Message processing error:', err);
    }
  });

  // Connection closure
  ws.on('close', () => {
    console.log(`User ${userId} disconnected`);
    if (ws.roomId && rooms[ws.roomId]) {
      rooms[ws.roomId] = rooms[ws.roomId].filter(client => client !== ws);
      if (rooms[ws.roomId].length === 0) {
        delete rooms[ws.roomId];
      }
    }
  });

  // Error handling
  ws.on('error', (err) => {
    console.error('WebSocket error:', err);
  });
});

server.listen(8080, () => {
  console.log('Signaling server running on port 8080');
});

NAT Traversal and TURN Servers

In complex network environments, TURN servers may be necessary:

  1. STUN Servers:
    • Used to obtain public IP addresses
    • Free public STUN servers available
  2. TURN Servers:
    • Used to relay media streams
    • Requires server resources
    • Paid services or self-hosted

Configuration Example:

const configuration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }, // Free STUN server
    { 
      urls: 'turn:your-turn-server.com:3478',
      username: 'your-username',
      credential: 'your-password'
    } // Self-hosted or paid TURN server
  ]
};

Multi-Device and Multi-Room Support

Extending one-to-one communication to multi-device or multi-room scenarios:

  1. Multi-Room Management:
    • Room ID assignment
    • User join/leave room
    • Room state monitoring
  2. Multi-Device Support:
    • Multiple device connections for the same user
    • State synchronization across devices
    • Media stream distribution

Testing and Debugging

Testing Strategies

  1. Local Testing:
    • Test within the same network
    • Test across different browsers
  2. Cross-Network Testing:
    • Test with different public IPs
    • NAT traversal testing
  3. Stress Testing:
    • Multi-room concurrent testing
    • Long-duration connection testing

Debugging Tools

  1. Browser Developer Tools:
    • WebRTC internal state monitoring (chrome://webrtc-internals)
    • Network panel analysis
  2. Network Packet Capture Tools:
    • Wireshark
    • tcpdump
  3. Logging Systems:
    • Client-side logs
    • Server-side logs

Using webrtc-internals:

  1. Enter chrome://webrtc-internals in Chrome’s address bar
  2. Locate the entry for your page tab
  3. Monitor key metrics:
    • ICE connection state
    • DTLS handshake state
    • Bandwidth usage
    • Packet loss rate

Common Issues and Solutions

Connection Establishment Failure

Possible Causes:

  • NAT traversal failure
  • Firewall blocking
  • Incorrect STUN/TURN server configuration

Solutions:

  1. Check ICE connection state
  2. Verify STUN/TURN server reachability
  3. Try adding TURN servers
  4. Check firewall settings

Media Stream Not Displaying

Possible Causes:

  • Device permission issues
  • Codec mismatch
  • Insufficient network bandwidth

Solutions:

  1. Check browser permissions
  2. Verify media stream status
  3. Adjust video resolution
  4. Check network conditions

High Latency or Stuttering

Possible Causes:

  • Insufficient network bandwidth
  • Poor routing path
  • Improper codec settings

Solutions:

  1. Reduce video resolution
  2. Adjust frame rate
  3. Check network routing
  4. Optimize codec parameters

Extensions and Optimizations

Performance Optimization

  1. Bandwidth Adaptation:
    • Dynamically adjust video quality
    • Bitrate control based on network conditions
  2. Hardware Acceleration:
    • Enable GPU acceleration
    • Optimize codec settings
  3. Connection Optimization:
    • Adjust ICE candidate priorities
    • Multi-path transmission

Feature Extensions

  1. Screen Sharing:
    • Use getDisplayMedia API
    • Optimize screen content encoding
  2. File Transfer:
    • Transmit via data channel
    • Chunk large files for transfer
  3. Multi-Party Calls:
    • Implement SFU architecture
    • Implement MCU architecture

Conclusion

Implementing one-to-one real-time communication with WebRTC involves multiple technical components, from signaling exchange to media transmission and security encryption. This document has covered:

  1. Complete Implementation Flow: From HTML structure to JavaScript code
  2. Key Technical Points: SDP exchange, ICE candidates, DTLS-SRTP encryption
  3. Practical Deployment Considerations: Signaling server, TURN server configuration
  4. Testing and Debugging Methods: Developer tools, network packet capture
  5. Common Issue Resolutions: Connection issues, media issues, performance issues

By mastering these concepts, developers can build stable and efficient one-to-one WebRTC communication applications. As WebRTC technology continues to evolve, future advancements will support more advanced features, such as improved NAT traversal and more efficient codecs.

Share your love