轻松实现WebRTC音视频通话

WebRTC

一、WebRTC简介

WebRTC(Web Real-Time Communication)是一项实时通信技术,它允许 Web 浏览器之间进行实时的音频、视频和数据传输,无需安装任何插件。这项技术为 Web 应用开发者提供了强大的工具,使得在网页上实现视频通话、屏幕共享、文件传输等实时交互功能变得轻而易举,极大地拓展了 Web 应用的功能边界。

二、WebRTC的原理

(一)架构

WebRTC 的架构主要由三部分组成:

  • 媒体处理层:负责处理音频和视频数据,包括编解码、降噪、回声消除、视频分辨率调整等操作,以确保媒体数据的质量和流畅性。

  • 网络传输层:主要负责数据的传输,它使用 UDP 协议来实现低延迟的实时数据传输,并通过各种传输控制机制,如拥塞控制、丢包重传等,来保证数据在网络中的可靠传输。

  • 信令层:用于在通信双方之间建立连接、交换媒体信息和控制信息等。信令的传输通常通过 HTTP 或 WebSocket 等协议来完成,它不负责传输媒体数据,而是为媒体数据的传输搭建通道。

(二)关键技术

  • 编解码技术

    • 音频编解码:WebRTC 支持多种音频编码格式,如 Opus、G.711 等。Opus 是一种高效的音频编码格式,它在低比特率下也能提供高质量的音频效果,并且具有很好的抗丢包性能,适用于实时通信场景。

    • 视频编解码:常用的视频编码格式有 VP8、VP9 和 H.264 等。VP8 和 VP9 是开源的视频编码格式,具有良好的压缩性能和视觉质量,能够在不同的网络条件下提供流畅的视频播放体验。H.264 也是一种广泛应用的视频编码格式,它在压缩效率和图像质量方面表现出色,但在某些情况下可能需要支付专利费用。

  • 网络传输技术

    • UDP 传输:WebRTC 选择 UDP 作为底层传输协议,是因为 UDP 具有低延迟、无连接的特点,适合实时数据的传输。然而,UDP 的不可靠性也带来了一些问题,如丢包、乱序等。为了解决这些问题,WebRTC 在 UDP 之上实现了自己的传输控制机制。

    • 拥塞控制:WebRTC 采用了基于发送方的拥塞控制算法,如 Google Congestion Control(GCC)。GCC 通过监测网络的拥塞情况,动态调整发送端的发送速率,以避免网络拥塞,保证数据的流畅传输。

    • 丢包重传:当发生丢包时,WebRTC 会根据丢包的情况和网络状况,决定是否进行重传。它采用了一些策略来减少重传对实时性的影响,如选择性重传、快速重传等。

  • 信令技术

    • SDP(Session Description Protocol):SDP 用于描述媒体会话的参数,如媒体类型、编码格式、传输地址等。在 WebRTC 中,通信双方通过交换 SDP 消息来协商媒体会话的参数,确保双方能够正确地接收和处理媒体数据。

    • ICE(Interactive Connectivity Establishment):ICE 用于在不同网络环境下建立通信连接,它通过收集本地和远程的网络候选地址,并进行连接测试,找到最佳的传输路径。ICE 还支持 NAT 穿越,使得在不同 NAT 环境下的设备能够进行通信。

    • STUN(Session Traversal Utilities for NAT):STUN 用于获取本地设备在 NAT 后的公网地址和端口号,它通过向 STUN 服务器发送请求,获取相关的地址信息,为 ICE 的连接建立提供支持。

    • TURN(Traversal Using Relays around NAT):当 ICE 无法直接建立连接时,TURN 作为中继服务器,帮助通信双方转发数据。TURN 服务器通常部署在公网上,它接收来自客户端的数据,并将其转发到目标客户端,实现数据的传输。

(三)HTTPS证书

HTTPS 认证证书的下载方式因证书来源而异,主要分为自签名证书和第三方受信任证书

自签名证书

自签名证书主要用于开发和测试环境,由自己生成,不存在从外部下载的情况,而是通过 OpenSSL 工具自行生成。以下是生成步骤:

1. 安装 OpenSSL
  • Windows:从 OpenSSL 官方网站 下载并安装。

  • Linux:使用包管理器安装,如在 Ubuntu 上执行 sudo apt-get install openssl

  • macOS:使用 Homebrew 安装,执行 brew install openssl

2. 生成私钥

打开终端,执行以下命令生成一个 2048 位的 RSA 私钥文件 server.key

openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:2048
3. 生成证书签名请求(CSR)

执行以下命令生成 CSR 文件 server.csr,过程中会要求输入一些信息,如国家、组织、域名等,可按需填写:

openssl x509 -req -in server.csr -signkey server.key -out server.crt -days 365
4. 生成自签名证书

使用私钥和 CSR 生成自签名证书 server.crt,有效期通过 -days 参数指定,这里设置为 365 天:

openssl x509 -req -in server.csr -signkey server.key -out server.crt -days 365
第三方受信任证书

第三方受信任证书由受信任的证书颁发机构(CA)签发,适用于生产环境,以下是下载步骤:

1. 选择证书颁发机构

常见的 CA 有 Let's Encrypt(提供免费证书)、DigiCert、Comodo 等。

2. 申请证书
  • 生成私钥和 CSR:与生成自签名证书的前两步相同,先生成私钥和 CSR 文件。

  • 提交申请:将 CSR 文件提交给所选的 CA,部分 CA 会要求验证域名所有权,验证方式通常有 DNS 验证、文件验证、邮件验证等。

3. 下载证书
  • Let's Encrypt:可以使用 Certbot 工具来获取和管理 Let's Encrypt 证书。安装 Certbot 后,在终端执行相应命令(如 certbot --nginx 用于 Nginx 服务器),Certbot 会自动完成证书的申请、下载和配置。证书文件通常存放在 /etc/letsencrypt/live/your_domain/ 目录下,包含 fullchain.pem(证书文件)和 privkey.pem(私钥文件)。

  • 其他商业 CA:申请通过后,登录 CA 的管理控制台,在相关页面找到已签发的证书,点击下载按钮。下载的证书文件可能有多种格式,如 .crt.pem 等,同时可能还需要下载中间证书。

(四)代码实现

基础界面实现
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>web</title>
    <link rel="stylesheet" href="./assets//css/index.css">
</head>
<body>
    <input id="roomId" type="text" value="" placeholder="请输入房间号">
    <input id="userId" type="text" value="" placeholder="请输入用户名">
    <button class="startBtn">开始通话</button>
    <button class="stopBtn">暂停/恢复通话</button>
    <button class="videoBtn">视频通话</button>
    <button class="screenBtn">屏幕共享</button>
    <div class="video-container">
        <div class="video-wrapper">
            <video autoplay controls muted id="offerVideo"></video>
            <div class="user-name" id="localUserName"></div>
        </div>
    </div>
    </div>
     <script type="module" src="./index.js"></script>
</body>
</html>
样式代码
body {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
    font-family: Arial, sans-serif;
}

input, button {
    padding: 8px 15px;
    margin: 5px;
    border: 1px solid #ddd;
    border-radius: 4px;
}

button {
    background-color: #4CAF50;
    color: white;
    border: none;
    cursor: pointer;
    transition: background-color 0.3s;
}

button:hover {
    background-color: #45a049;
}

.video-container {
    margin-top: 20px;
    display: flex;
    justify-content: center;
    gap: 20px;
    flex-wrap: wrap;
}

video {
    width: 400px;
    height: 300px;
    border-radius: 8px;
    background-color: #f0f0f0;
    margin-bottom: 10px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.video-wrapper {
    position: relative;
    margin-bottom: 20px;
}

.user-name {
    text-align: center;
    padding: 5px;
    background-color: rgba(0, 0, 0, 0.7);
    color: white;
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    font-size: 14px;
}
JavaScript API
import { getLocalMediaStream, setLocalVideoStream,createPeerConnection, createVideoEle,setRemoteVideoStream,getLocalScreenMediaStream} from './utils/common.js'
import {io} from './utils/socket.io.esm.min.js'
const roomInput=document.querySelector('#roomId')
const userInput=document.querySelector('#userId')
const startBtn=document.querySelector('.startBtn')
const stopBtn=document.querySelector('.stopBtn')
const videoBtn=document.querySelector('.videoBtn')
const screenBtn=document.querySelector('.screenBtn')

let offerVideo=document.querySelector('#offerVideo')
let localStream=await getLocalMediaStream({video:true,audio:true})
setLocalVideoStream(offerVideo,localStream)

// 监听用户名输入变化,实时更新本地视频用户名
userInput.addEventListener('input', () => {
    document.querySelector('#localUserName').textContent = userInput.value;
});

//房间人数是否已满
let isRoomRull=false 
//房间号和用户名
let roomId
let userId
//socket对象
let client
let serverUrl='wss://192.168.43.104:3000/'
//第一次进入
let isInited=false;


/**
 * @type {RTCPeerConnection}
 */
let peer

let isStopAudio=false
let isStopVideo=false

startBtn.addEventListener('click',()=>{
    if(isRoomRull){
        alert('房间人数已满')
        return
    }
    roomId=roomInput.value
    userId=userInput.value
    // console.log(roomId,userId);
    if(!client){
        client=new io(serverUrl,{
            reconnectDelayMat:10000,
            transports:['websocket'],
            query:{
                roomId,
                userId
            }
        })
        client.on('connect',()=>{
            console.log('Connection successful');
        })
        client.on('disconnect',()=>{
            console.log('Connection disconnected');
        })
        client.on('error',()=>{
            console.log('Connection error');
        })
        client.on('people-count-msg',async(count)=>{
            console.log('count',count);
            if(count==1){
                isInited=true
            }
            peer=createPeerConnection()
            localStream.getTracks().forEach(track=>{
                peer.addTrack(track,localStream)
            })
            peer.onicecandidate=(event)=>{
                if(event.candidate){
                    client.emit('candidate-msg',event.candidate)
                }
            }
            peer.ontrack=(event)=>{
                // 根据isInited状态确定远程用户名称
                let remoteUserName = isInited ? count.userId : userId;
                let videoEle=createVideoEle(2, remoteUserName)
                setRemoteVideoStream(videoEle,event.track)
            }
            if(!isInited){
                let offerSDP=await peer.createOffer()
                await peer.setLocalDescription(offerSDP)
                console.log("1. B 发送offer 给 A");
                client.emit('offer-sdp-msg',offerSDP)
            }
            isInited=true
        })
        client.on('room-full',()=>{
            alert('当前房间人数已满')
            isRoomRull=true
            return
        })

        //A会监听这个事件
        client.on('offer-sdp-msg',async(offerSDP)=>{
            console.log("2. A接受B的offer");
            await peer.setRemoteDescription(offerSDP)
            let answerSDP=await peer.createAnswer()
            await peer.setLocalDescription(answerSDP)
            //A和B已经收到B的offer-sdp-msg,所以需要把answerSDP发送给B
            console.log("3.A发送answer");
            client.emit('answer-sdp-msg',answerSDP)
        })
        //B
        client.on('answer-sdp-msg',async(answerSDP)=>{
            console.log("4. B接收A的answer");
            await peer.setRemoteDescription(answerSDP)
        })
        client.on('candidate-msg',async(candidate)=>{
            console.log('5. 交换candidate信息');
            
            await peer.addIceCandidate(candidate)
        })
    }
})

stopBtn.addEventListener('click',()=>{
    if(peer){
        isStopAudio=!isStopAudio
        isStopVideo=!isStopVideo
        peer.getSenders().find(sender=>sender.track.kind==='audio').track.enabled=!isStopAudio;
        peer.getSenders().find(sender=>sender.track.kind==='video').track.enabled=!isStopVideo;

    }
})

videoBtn.addEventListener('click',async()=>{
    let newStream=await getLocalMediaStream({
       video:true,audio:true
    });
    if(newStream){
        localStream=newStream
        setLocalVideoStream(offerVideo,localStream)
        //让通话用户也能接受到实时的画面
        localStream.getVideoTracks().forEach(track=>{
            peer.getSenders().find(sender=>sender.track.kind==='video').replaceTrack(track)
        })
    }
})

screenBtn.addEventListener('click',async()=>{
    let newStream=await getLocalScreenMediaStream({
        video:{
            cursor:'always'|'never'|'motion',
            displaySurface:'monitor'|'window'|'application'|'browser'
        }
    });
    if(newStream){
        localStream=newStream
        setLocalVideoStream(offerVideo,localStream)
        //让通话用户也能接受到实时的画面
        localStream.getVideoTracks().forEach(track=>{
            peer.getSenders().find(sender=>sender.track.kind==='video').replaceTrack(track)
        })
    }
    
})
公共函数
/**
 * @param {MediaStreamConstraints} constraints
 */
// 导出一个异步函数,用于获取本地媒体流
export const getLocalMediaStream=async(constraints)=>{
    try{
        // 使用navigator.mediaDevices.getUserMedia方法获取本地媒体流
        let stream=await navigator.mediaDevices.getUserMedia(constraints);
        // 返回获取到的媒体流
        return stream
    }catch(error){
        // 如果获取媒体流失败,则打印错误信息
        console.log("error",error);
    }
}

/**
 * 获取共享屏幕数据流
 * @param {MediaStreamConstraints} constraints
 */

// 导出一个异步函数,用于获取本地屏幕媒体流
export const getLocalScreenMediaStream=async(constraints)=>{
    try{
        // 使用navigator.mediaDevices.getDisplayMedia方法获取屏幕媒体流
        let stream=await navigator.mediaDevices.getDisplayMedia(constraints);
        // 返回获取到的媒体流
        return stream
    }catch(error){
        // 如果获取媒体流失败,则打印错误信息
        console.log("error",error);
    }
}


/**
 * @param {HTMLVideoElement} ele
 * @param {MediaStream} newStream
 */
// 导出一个异步函数,用于设置本地视频流
export const setLocalVideoStream=async(ele,newStream)=>{
    // 如果ele存在
    if(ele){
        // 获取ele的旧视频流
        let oldStream=ele.srcObject;
        // 如果旧视频流存在
        if(oldStream){
            //videoTrack和audioTrack
            oldStream.getTracks().forEach(track=>{
                oldStream.removeTrack(track)
            })
        }
        ele.srcObject=newStream;
    }
}


/**
 * @params {HTMLVideoElement} ele
 * @param {MediaStreamTrack} track
*/
// 导出一个函数,用于设置远程视频流
export const setRemoteVideoStream=(ele,track)=>{
    // 如果ele存在
    if(ele){
        // 获取ele的srcObject
        let stream=ele.srcObject;
        // 如果stream存在
        if(stream){
            // 将track添加到stream中
            stream.addTrack(track)
        }else{
            // 否则创建一个新的MediaStream
            let newStream=new MediaStream();
            // 将track添加到新的MediaStream中
            newStream.addTrack(track)
            // 将新的MediaStream赋值给ele的srcObject
            ele.srcObject=newStream
        }
    }
}

export const createPeerConnection=()=>{
    const peer=new RTCPeerConnection({
        /** 
         * 
         * bundlePolicy:这个选项用于控制ICE候选信息的捆绑策略,在这个例子中,它被注释掉了,所以默认值为 "balanced"。这意味着如果可能,会尝试所有的ICE候选信息捆绑在一起发送,以减少网络延迟
         * 
         * rtcpMuxPolicy:这个选项用于控制是否使用RTP/Rtcp多路复用。这这个例子中,它也被注释掉了,所以默认值为 "require"。这意味着如果浏览器支持RTP/Rtcp多路复用,那么就会使用它,否则,将不会使用它。
         * 
         * iceTransportPolicy:这个选项用于控制ICE传输策略。在这个例子中,它被设置为 "relay",表示强制服务器转发所有的TCE候选信息,这在某些情况下可能是有用的,例如当服务器位于NAT后面时
         * 
         * iceServers:这是一个数组,包含了ICE服务器的信息。在这个例子中,只有一个ICE服务器,其URL为 "stun:stun.l.google.com:19302"。这是一个公共的ICE服务器,用于处理NAT穿透和端口映射
         */
        // bundlePolicy:"max-bundle",
        // rtcpMuxPolicy:"require",
        // iceTransportPolicy:"relay",
        // iceServers:[
        //     {urls:'stun:stun.l.google.com:19302'}
        // ]
    });
    return peer
}

// 导出一个函数,用于创建视频元素
export const createVideoEle=(count, userName)=>{
    // 获取视频容器元素
    let video_container=document.querySelector('.video-container');
    // 创建一个视频包裹元素
    let videoWrapper = document.createElement('div');
    // 设置视频包裹元素的类名
    videoWrapper.className = 'video-wrapper';
    /**
     * @type {HTMLVideoElement}
     */
    // 获取指定id的视频元素
    let video=document.querySelector('#video'+count)
    // 如果没有找到指定id的视频元素
    if(!video){
        // 创建一个视频元素
        video=document.createElement('video')
        // 设置视频元素为静音
        video.muted=true
        // 设置视频元素自动播放
        video.autoplay=true
        // 设置视频元素显示控制条
        video.controls=true
        // 设置视频元素的id
        video.id='video'+count
        // 创建一个用户名元素
        let userNameDiv = document.createElement('div');
        // 设置用户名元素的类名
        userNameDiv.className = 'user-name';
        // 设置用户名元素的文本内容
        userNameDiv.textContent = userName;
        // 将视频元素和用户名元素添加到视频包裹元素中
        videoWrapper.appendChild(video);
        videoWrapper.appendChild(userNameDiv);
        // 将视频包裹元素添加到视频容器元素中
        video_container.appendChild(videoWrapper)
    }
    // 返回视频元素
    return video
}
项目入口
// 导入所需的核心模块
import express from 'express'        // Express框架,用于创建Web服务器
import fs from 'fs'                  // 文件系统模块,用于读取SSL证书文件
import path from 'path'             // 路径处理模块
import https from 'https'           // HTTPS服务器模块
import {Server} from 'socket.io'    // WebSocket服务器,用于实时通信

// 导入自定义工具和服务模块
import {getIpAddress} from './utils/common.js'    // 获取本机IP地址的工具函数
import {initSDPServer} from './server/sdp.js'     // WebRTC信令服务器初始化模块
// import {initSDPServer} from './server/sdp2.js' // 多对多通信的信令服务器(已注释)

// SSL证书配置,用于HTTPS服务器
const options = {
  key: fs.readFileSync(path.resolve('./ssl/server.key')),    // 私钥文件
  cert: fs.readFileSync(path.resolve('./ssl/server.crt'))    // 证书文件
}

// 创建Express应用实例
const app = express()
// 配置静态文件服务,将public目录下的文件作为静态资源提供
app.use(express.static('./public'))

// 创建HTTPS服务器
const httpsServer=https.createServer(options,app)

// 创建WebSocket服务器实例
// allowEIO3: 允许Engine.IO 3版本客户端连接
// cors: 启用跨域资源共享
const io=new Server(httpsServer,{allowEIO3:true,cors:true})

// 初始化WebRTC信令服务器
initSDPServer(io)

// 启动服务器,监听3000端口
httpsServer.listen(3000,()=>{
    // 获取服务器访问地址,如果无法获取IP则显示"网络不可用"
    let str=getIpAddress()?`https://${getIpAddress()}:3000`:'网络不可用';
    console.log(`服务器启动成功,访问地址:${str}`)
})
信令服务器
import { Server } from "socket.io";

// 用于存储房间信息的Map,key为房间ID,value为房间内的人数
const roomMap=new Map()

/**
 * @param {Server} io - Socket.io服务器实例
 * 初始化WebRTC信令服务器
 */ 
export const initSDPServer=(io)=>{
    // 监听客户端连接事件
    io.on("connection",(socket)=>{
        onEvent(socket)
    })
}

/**
 * 处理所有socket相关的事件
 * @param {Socket} socket - 客户端的socket连接实例
 */
const onEvent=(socket)=>{
    // 从socket请求中获取房间ID和用户ID
    let {roomId,userId}=socket.request._query
    
    // 房间管理:如果房间不存在则创建,存在则人数+1
    if(!roomMap.get(roomId)){
        roomMap.set(roomId,1)
    }else{
        roomMap.set(roomId,roomMap.get(roomId)+1)
    }
    console.log('新用户加入:房间人数',roomMap.get(roomId));

    // 监听用户断开连接事件
    socket.on('disconnect',()=>{
        console.log('Client disconnect');
        // 通知房间内其他用户有人离开
        socket.to(roomId).emit('client-leave',userId+':leave')
        // 更新房间人数
        roomMap.set(roomId,roomMap.get(roomId)-1)
        console.log('用户离开:房间总人数',roomMap.get(roomId));
    })

    // 房间人数限制:最多2人
    if(roomMap.get(roomId)>2){
       socket.emit('room-full',true)
       return
    }

    // 将用户加入指定房间
    socket.join(roomId)
    // 向当前用户发送房间人数信息
    socket.emit('people-count-msg',{count: roomMap.get(roomId), userId})
    // 向房间内其他用户广播人数信息
    socket.to(roomId).emit('people-count-msg',{count: roomMap.get(roomId), userId})

    // WebRTC信令相关事件处理
    // 处理offer信令:当B加入房间时,转发B的offer给A
    socket.on('offer-sdp-msg',(offerSDP)=>{
        socket.to(roomId).emit('offer-sdp-msg',offerSDP)
    })

    // 处理answer信令:转发A的answer给B
    socket.on('answer-sdp-msg',(answerSDP)=>{
        socket.to(roomId).emit('answer-sdp-msg',answerSDP)
    })

    // 处理ICE候选者信息:在双方之间转发ICE候选者
    socket.on('candidate-msg',(candidate)=>{
        socket.to(roomId).emit('candidate-msg',candidate)
    })
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值