Socket.io实现音视频通话

一、一对一通话设计

对于前端开发人员来讲,主要关注对象是RTCPeerConnection类,一个js封装好的类,直接调用。主要分为四个大模块来分析:信令设计、媒体协商、加入Stream/Track、网络协商。

根据下面整个流程图片

Socket.io的基本使用

服务端

js 复制代码
io.on('connection', (socket) => { // 处理客户端连接事件 console.log('a user connected'); });

客户端

客户端和服务器端都可以发送消息。可以使用socket.emit()方法从客户端发送消息到服务器端,也可以使用socket.emit()方法从服务器端发送消息到客户端。例如,在客户端中发送一条消息:

js 复制代码
socket.emit('chat message', 'Hello World!');

在服务器端接收该消息并回复:

js 复制代码
io.on('connection', (socket) => { 
    socket.on('chat message', (msg) => {
        console.log('message: ' + msg); 
        io.emit('chat message', msg); 
     }); 
});

客户端同样监听:

js 复制代码
socket.on('chat message', (msg) => {
    console.log('message: ' + msg); 
});

为后面的信令设计做铺垫,就是按照发送-接收来进行数据的交换。

1.信令设计

信令服务器(Signaling Server)是一种用于实时通信系统的服务器,它起到了协调和传递消息的作用。在实时通信应用中,例如语音通话、视频通话和实时消息等,通信双方需要通过信令服务器进行交互,以建立连接、交换媒体信息和控制会话的行为。这里使用Socket.io 服务器,用Node.js创建后台服务

服务端

js 复制代码
const express=require('express');
const app=express();
const {createServer} =require('http');
const {Server} = require("socket.io");
const httpServer=createServer(app);

var path=require("path");
app.use(express.static(path.join(__dirname,'client')));
app.get("/",function(req,res){
    res.sendFile(__dirname+"/index.html");
})
//创建server
const io=new Server(httpServer);

客户端

js 复制代码
/* 连接socket.io服务器 本地的3000端口作为测试*/
var socket=io('http://localhost:3000')

2.媒体协商

双方进行音视频通信的时候,必须先知道对方的媒体格式是什么,都支持怎么样的格式才能进行通信,而双方进行交换的媒体格式称为SDP信息,所以必须先交换SDP信息,这样双方才能知根知底,而交换SDP的过程,也称为"媒体协商"

客户端创建RTCPeerConnection对象并添加本地的媒体流

js 复制代码
//创建RTCPeerConnection
function createPeerConnection(){
    pc=new RTCPeerConnection(null);         //pc对象
    localStream.getTracks().forEach(track=>pc.addTrack(track,localStream)); //pc添加媒体流
}

媒体流通过getUserMedia获取:

js 复制代码
navigator.mediaDevices.getUserMedia({
        audio:true,
        video:true
    }).then(()=>{
         ......把媒体流放进相应的video标签
    })
    .catch(e=>{
        alert('error:'+e.name);
    })

交换SDP信息,先把SDP通过offer发送给信令服务器,再让服务器转发给另一个客户端:

js 复制代码
pc.createOffer().then(offer=>{
            pc.setLocalDescription(offer).then(function(){
                socket.emit("offer",SDP信息)
            }).catch(()=>{
                console.log('offer Error')
            })
        }).catch(()=>{
            console.log('createOffer Error')
        })   

服务端接收offer信息,再进行相应的转发:

js 复制代码
//服务器接收SDP offer
    socket.on("offer",res=>{
        //拿到要发送offer房间号ID
        console.log("服务端接收offer并进行转发操作")
    })

3.加入Stream/Track

上面提到在创建RTCPeerConnection的时候,添加了媒体流的操作

js 复制代码
localStream.getTracks().forEach(track=>pc.addTrack(track,localStream));

接下来要把远端传过来的媒体流加入到自己的本地ontrack,这样才能获取对方的音视频:

js 复制代码
pc.ontrack=function(e){
    remoteStream=e.streams[0];
    console.log("远端媒体流",remoteStream)
    //再把远端的媒体流放到自己的标签组件里面
};

4.网络协商

彼此要了解对方的网络情况,这样才有可能找到一条相互通讯的链路:

  1. 获取外网IP地址映射
  2. 通过信令服务器(signal server)交换"网络信息"

想要获取对方的外网IP,这里需要介绍stun服务器

STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。该协议由RFC 5389定义。

当打洞不成功的时候,我们需要一个中继服务器来进行转发:

TURN

TURN的全称为Traversal Using Relays around NAT,是STUN/RFC5389的一个拓展,主要添加了Relay功能。如果终端在NAT之后, 那么在特定的情景下,有可能使得终端无法和其对等端(peer)进行直接的通信,这时就需要公网的服务器作为一个中继, 对来往的数据进行转发。这个转发的协议就被定义为TURN。

需要同学们自行去部署以上服务器

交换ICE

js 复制代码
// 使用STUN服务器创建RTCPeerConnection对象 
const pc = new RTCPeerConnection({ 
    iceServers: [{ 
        urls: '......' 
        }] 
}); 
// 监听icecandidate事件并发送ICE候选地址给对等端 
pc.onicecandidate = event => { 
    if (event.candidate) { ; 
        socket.emit('icecandidate', candidate); 
    } 
}; 
// 接收对等端发送的ICE候选地址并添加到RTCPeerConnection中 
socket.on('icecandidate', candidate => { 
    pc.addIceCandidate(new RTCIceCandidate(candidate)); 
});

注意!

以上是socket.io实现WebRTC一对一通信的基本原理,代码如下:

客户端Html(要引入adapter-latest.js文件)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RTC</title>
</head>
<body>
    <h1>webRTC Demo</h1>
    <div class="entry">
        <input type="text" id="zero-roomId" placeholder="输入房间号">
        <button id="join">加入</button>
        <button id="leave">离开</button>
    </div>
    <div class="video-box">
        <video id="localVideo" autoplay muted playsinline>本地</video>
        <video id="remoteVideo" autoplay playsinline>远端</video>
    </div>
</body>
<!-- 引入socketio -->
<script src="/socket.io/socket.io.js"></script>
<script src="./js/index.js"></script>
<script src="./js/adapter-latest.js"></script>

</html>

客户端js代码

js 复制代码
var localUserId = Math.random().toString(36).substring(2); // 本地uid
var remoteUserId = -1;      // 对端
var roomId = 0;

var localVideo=document.getElementById('localVideo')
var remoteVideo=document.getElementById('remoteVideo')
var joinRoom=document.getElementById('join')
var leaveRoom=document.getElementById('leave')
var localStream=null;       //本地媒体流
var remoteStream=null;      //远端媒体流
var pc=null;
/* 连接socket.io服务器 */
var socket=io('http://localhost:3000')

//创建RTCPeerConnection
function createPeerConnection(){
    pc=new RTCPeerConnection(null);
    pc.onicecandidate=handleIceCandidate;
    pc.ontrack=handleRemoteStreamAdd;

    localStream.getTracks().forEach(track=>pc.addTrack(track,localStream));
}

//ice候选项方法
function handleIceCandidate(e){
    if(e.candidate){
        var candidateJson={
            "label":e.candidate.sdpMLineIndex,
            "id":e.candidate.sdpMid,
            "candidate":e.candidate.candidate
        };
        var jsonMsg={
            "cmd":"candidate",
            "roomId":roomId,
            "uid":localUserId,
            "remoteUid":remoteUserId,
            "msg":candidateJson
        };
        socket.emit("candidate",jsonMsg)
    }
}

//获取远端媒体流处理
function handleRemoteStreamAdd(e){
    remoteStream=e.streams[0];
    console.log("远端媒体流",remoteStream)
    remoteVideo.srcObject=remoteStream;
}

//加入房间回调方法
function handleRemoteNewPeer(res){
    remoteUserId=res.remoteUid
}


//发送offer方法
function doOffer(){
    console.log("doOffer方法")
    if(pc==null){
        createPeerConnection();
    }else{
        console.log("doOffer方法进入offer,pc为:",pc)
        pc.createOffer().then(offer=>{
            pc.setLocalDescription(offer).then(function(){
                var jsonMsg={
                    "cmd":"offer",
                    "roomId":roomId,
                    "uid":localUserId,
                    "remoteUid":remoteUserId,
                    "msg":offer
                }
                socket.emit("offer",jsonMsg)
            }).catch(()=>{
                console.log('offer Error')
            })
        }).catch(()=>{
            console.log('createOffer Error')
        })   
    }
}

//发送answer方法
function doAnswer(){
    pc.createAnswer().then(answer=>{
        pc.setLocalDescription(answer).then(function(){
            var jsonMsg={
                "cmd":"answer",
                "roomId":roomId,
                "uid":localUserId,
                "remoteUid":remoteUserId,
                "msg":answer
            }
            socket.emit("answer",jsonMsg)
        }).catch(()=>{
            console.log('answer Error')
        })
    }).catch(()=>{
        console.log('createAnswer Error')
    })   
}

//加入房间的方法
function doJoin(roomId){
    var jsonMsg={
        "cmd":"join",
        "roomId":roomId,
        "uid":localUserId,
    }
    socket.emit('join',jsonMsg)
    
}

//离开房间方法
function doLeave(){
    var jsonMsg = {
        'cmd': 'leave',
        'roomId': roomId,
        'uid': localUserId,
    };

    //置空对方和本地的媒体流
    remoteVideo.srcObject=null;
    localVideo.srcObject=null;
    //关闭RTCPeerConnection
    if(pc!=null){
        pc.close();
        pc=null;
    }
    //媒体轨道停止,关闭本地流
    if(localStream != null){
        localStream.getTracks().forEach(track=>{
            track.stop()
        })
    }
    socket.emit('leave',jsonMsg)
}

//有人创建了房间
socket.on("new-peer",res=>{ 
    if(res!=="createRoom"){
        console.log("有人加入进来id:",res)
        handleRemoteNewPeer(res)
    }
    doOffer();
})

//回应加入信息
socket.on("resp-join",res=>{
    console.log("房间里面已经有人id:",res)
})

//收到离开房间信息
socket.on("peer-leave",res=>{
    console.log("有人离开房间id:",res)
    //把对方视频置空
    remoteVideo.srcObject=null;
    
})

//接收远端offer信息
socket.on("peer-offer",res=>{
    console.log("客户端接收peer-offer",res)
    if(pc == null){
        createPeerConnection()
    }
    pc.setRemoteDescription(res.msg);
    doAnswer();
})

//接收远端answer信息
socket.on("peer-answer",res=>{
    console.log("客户端接收peer-answer",res)
    pc.setRemoteDescription(res.msg);
})

//接收远端candidate信息
socket.on("peer-candidate",res=>{
    console.log("客户端接收peer-candidate",res)
    var candidateMsg={
        "sdpMLineIndex":res.msg.label,
        "sdpMid":res.msg.id,
        "candidate":res.msg.candidate
    }
    var candidate=new RTCIceCandidate(candidateMsg)
    pc.addIceCandidate(candidate).catch(e=>{
        console.log("pc.addIceCandidate Error")
    })
})



//关闭媒体
function closeLocalStream(stream){
    // 停止所有媒体轨道
    stream.getTracks().forEach((track) => {
        track.stop();
    });
    // 关闭视频元素
    localVideo.pause();
    localVideo.srcObject = null;
}

//把视频流赋给dom标签
function openLocalStream(stream){
    console.log("打开了本地流")
    doJoin(roomId)  //调用加入房间的方法
    
    //房间已满人通知
    socket.on("roomFull",res=>{
        alert(res)
        closeLocalStream(stream)
        return
    })
    localVideo.srcObject=stream;
    localStream=stream;

    roomFull=false
}

//初始化打开摄像头和麦克风
function initLocalStream(){
    navigator.mediaDevices.getUserMedia({
        audio:true,
        video:true
    }).then(openLocalStream).catch(e=>{
        alert('error:'+e.name);
    })

}

//加入房间按钮
joinRoom.onclick=function(){
    roomId=document.getElementById("zero-roomId").value
    if(!roomId) {
        alert("请输入房间ID") 
        return
    }
    initLocalStream() //调用打开摄像头和麦克风
}

//离开房间按钮
leaveRoom.onclick=function(){
    doLeave()
}

服务端代码

js 复制代码
const express=require('express');
const app=express();
const {createServer} =require('http');
const {Server} = require("socket.io");
const httpServer=createServer(app);

var path=require("path");
app.use(express.static(path.join(__dirname,'client')));
app.get("/",function(req,res){
    res.sendFile(__dirname+"/index.html");
})

const io=new Server(httpServer);
const roomMap = new Map(); // 保存房间信息
//io连接回调
io.on("connection",(socket)=>{
    console.log('socket连接成功')
    socket.peer=null
    //有人加入房间
    socket.on("join", res=> {
        //拿到要加入房间号ID
        let room=roomMap.get(res.roomId);
        
        //判断是否房间已经被创建
        if(!room){
            room=new Set();
            roomMap.set(res.roomId,room);
            room.add(res)
            socket.join(res.roomId)
            socket.peer=res
            socket.emit("new-peer","createRoom")
        }else if(room.size >=2){
            socket.emit("roomFull","该房间已满人!");
            return;
        }else{       
            socket.join(res.roomId)
            room.add(res)
            console.log('---room-----',roomMap)
            socket.to(res.roomId).emit("new-peer",res)
            socket.peer=res
            socket.emit("resp-join",Array.from(room).filter(item=>item.uid!==res.uid))
        }

    });
    
    //有人离开房间
    socket.on("leave",res=>{
        //拿到要离开房间号ID
        let room=roomMap.get(res.roomId);
        
        if (room) {
            const userToRemove = Array.from(room).find(item => item.uid === res.uid);
            if (userToRemove) {
                room.delete(userToRemove);
                console.log(`用户 ${res.uid} 已从房间 ${res.roomId} 中删除`);
                console.log('此时房间还有:',room);
            }
            if(room.size === 0) {
              roomMap.delete(res.roomId);
            } else {
                var jsonMsg={
                    "cmd":"peer-leave",
                    "remoteUid":res.uid
                }
              socket.to(res.roomId).emit("peer-leave", jsonMsg);
            }
        }
        socket.leave(res.roomId);
    })

    //服务器接收SDP offer
    socket.on("offer",res=>{
        //拿到要发送offer房间号ID
        console.log("服务端接收offer")
        let room=roomMap.get(res.roomId);
        if(room==null){
            console.error("room is null"+res.roomId)
            return
        }
        socket.broadcast.to(res.roomId).emit("peer-offer",res)
    })

    //服务器接收SDP answer
    socket.on("answer",res=>{
        //拿到要发送offer房间号ID
        console.log("服务端接收answer")
        let room=roomMap.get(res.roomId);
        if(room==null){
            console.error("room is null",res.roomId)
            return
        }
        socket.broadcast.to(res.roomId).emit("peer-answer",res)

        
    })

    //服务器接收candidate
    socket.on("candidate",res=>{
        console.log("服务端接收candidate")
        //拿到要发送offer房间号ID
        let room=roomMap.get(res.roomId);
        if(room==null){
            console.error("room is null"+res.roomId)
            return
        }  
        
        socket.broadcast.to(res.roomId).emit("peer-candidate",res)
    })
    //连接回调出错
    socket.on("error", (res) => {
        
    });

    //断开连接回调
    socket.on("disconnect", () => {
        console.log("有客户端断开连接",socket.peer);
        //拿到要离开房间号ID
        if(socket.peer){
            const peer=socket.peer
            let room=roomMap.get(peer.roomId);
            if (room) {
                const userToRemove = Array.from(room).find(item => item.uid === peer.uid);
                if (userToRemove) {
                    room.delete(userToRemove);
                    console.log(`用户 ${peer.uid} 已从房间 ${peer.roomId} 中删除`);
                    console.log('此时房间还有:',room);
                }
                if(room.size === 0) {
                  roomMap.delete(peer.roomId);
                } else {
                    var jsonMsg={
                        "cmd":"peer-leave",
                        "remoteUid":peer.uid
                    }
                  socket.to(peer.roomId).emit("peer-leave", jsonMsg);
                }
            }
            socket.leave(peer.roomId);
        }

    });
})

httpServer.listen(3000,function(){
    console.log('服务器启动成功了:http://localhost:3000')
});
相关推荐
崔庆才丨静觅11 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606112 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了12 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅12 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅13 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment13 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax