欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

简易教程:使用Docker、Coturn、Janus、SRS和FFmpeg搭建WebRTC服务器

最编程 2024-08-10 20:50:36
...

webrtc多方通信方案

Mesh 方案

Mesh方案即多个终端之间两两进行连接,形成一个网状结构。

比如 A、B、C 三个终端进行多对多通信,当 A 想要共享它的音视频流时,它需要分别向 B 和 C 发送数据。当B想要共享媒体,就需要分别向 A、C 发送数据,依次类推。

Mesh方案对各终端的带宽要求比较高。

优点:

不需要服务器中转数据,STUN/TUTN 只是负责 NAT 穿越,利用现有 WebRTC 通信模型就可以实现,而不需要开发媒体服务器。

充分利用了客户端的带宽资源。

节省了服务器资源,因为服务器带宽往往是专线,价格昂贵,所以这种方案可以很好地控制成本。

缺点:

共享端共享媒体流的时候,需要给每一个参与人都转发一份媒体流,对上行带宽的占用很大。参与人越多,占用的带宽就越大。除此之外,对 CPU、Memory 等资源也是极大的考验。客户端的机器资源、带宽资源往往是有限的,资源占用和参与人数成线性相关的。这样导致 多人通信的规模非常有限,超过 4 个人时,就会有非常大的问题。

在多人通信时,如果有部分人不能实现 NAT 穿越,还想与其他人互通,就显得很麻烦,需要做出更多的可靠性设计。

MCU(Multipoint Conferencing Unit)方案

MCU方案由一个服务器和多个终端组成一个星形结构。

各终端将自己要共享的音视频流发送给服务器,服务器端会将在同一个房间中的所有终端的音视频流进行混合,最终生成一个混合后的音视频流再发给各个终端,这样各终端就可以看到 / 听到其他终端的音视频了。

实际上服务器端就是一个音视频混合器,这种方案服务器的压力会非常大。

优点:

技术成熟,在硬件视频会议中应用非常广泛。

作为音视频网关,通过解码、再编码可以屏蔽不同编解码设备的差异化,满足更多客户的集成需求,提升用户体验和产品竞争力。

将多路视频混合成一路,所有参与人看到的是相同的画面,客户体验好。

缺点:

重新解码、编码、混流,需要大量的运算,对 CPU 资源的消耗很大。

重新解码、编码、混流还会带来延迟。

由于机器资源耗费很大,所以 MCU 所提供的容量有限,一般十几路视频就是上限了。

SFU(Selective Forwarding Unit)方案

SFU方案也是由一个服务器和多个终端组成,但与 MCU 不同的是,SFU 不对音视频进行混流,收到某个终端共享的音视频流后,就直接将该音视频流转发给房间内的其他终端。它实际上就是一个音视频路由转发器。

优点:

由于是数据包直接转发,不需要编码、解码,对 CPU 资源消耗很小。

直接转发也极大地降低了延迟,提高了实时性。

带来了很大的灵活性,能够更好地适应不同的网络状况和终端类型。

缺点:

由于是数据包直接转发,参与人观看多路视频的时候可能会出现不同步,相同的视频流,不同的参与人看到的画面也可能不一致。

参与人同时观看多路视频,在多路视频窗口显示、渲染等会带来很多麻烦,尤其对多人实时通信进行录制,多路流也会带来很多回放的困难。

目前许多 SFU 方案都支持 SVC 模式和 Simulcast 模式,用于适配 WiFi、4G 等不同网络状况,以及 Phone、Pad、PC 等不同终端设备。

docker环境安装

手把手搭建CICD-k8s全流程(带完整视频)

补充jenkins搭建

最简单的vue项目快速搭建jenkins(带视频)

设置yum源

sed -e 's|^mirrorlist=|#mirrorlist=|g' \
         -e 's|^#baseurl=http://mirror.centos.org|baseurl=https://mirrors.tuna.tsinghua.edu.cn|g' \
         -i.bak \
         /etc/yum.repos.d/CentOS-*.repo

更新缓存

yum makecache

安装Docker环境

#关闭防火墙
systemctl stop firewalld && systemctl disable firewalld

#配置 docker-ce 国内 yum 源(阿里云)
yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

#安装基础软件包
yum install -y wget lsof net-tools nfs-utils lrzsz gcc gcc-c++ make cmake libxml2-devel openssl-devel curl curl-devel unzip sudo ntp libaio-devel wget vim ncurses-devel autoconf automake zlib-devel python-devel epel-release openssh-server socat ipvsadm conntrack yum-utils

#安装 docker 依赖包
yum install -y yum-utils device-mapper-persistent-data lvm2

#安装 docker-ce
yum install docker-ce docker-ce-cli containerd.io

#安装docker-compose
yum install docker-compose

#设置开机启动
systemctl enable docker

#启动Docker服务
systemctl start docker

docker方式安装coturn

在开始通话之前,就必须进行 ICE 协商获取双方的 IP 以及端口,如果通话是区域网则简单,直接区域网 IP 即可,但通话如果是在公共复杂网络,且客户端并未直接接入公共网路,而是仅接入到本地 NAT 网关中呢?

此时是无法直接获取通话客户端的公有 IP 的。这个时候就需要特定的东西去获取客户端真实且互相可以抵达的公有 IP,而 STUN 服务器的作用就是干这个的,有了它就可以让通话双方发现对方的公共通讯地址。

接上面,能发现公有地址不代表就能进行通话,进行通话的前提是允许客户端能够在相应端口进行上行和下行数据的发送和接收。就好比你电脑启动了一个服务但是防火墙是开着的,那么别人访问你的服务还是没法访问的。此时 TURN 来了,它的作用就是充当双方中继地址,将双方的流量中继到当前服务器从而实现双方流量的交换

[
    { url: "stun:stun.l.google.com:19302"},// 谷歌的公共服务 
]

使用官方镜像

docker run -d --network=host coturn/coturn

拷贝待测试挂载 sudo docker run -d --privileged=true --name=mycoturn --network=host \ -v /home/ubuntu/turnserver/turnserver.conf:/my/turnserver.conf \ coturn/coturn:4.6 -c /my/turnserver.conf

image.png

查看端口

netstat -anpl | grep turnserver
或者
lsof -i:3478

image.png

关闭防火墙

systemctl stop firewalld && systemctl disable firewalld

Trickle ICE 测试

打开地址 webrtc.github.io/samples/src…

分别测试 stun 和 turn

1、测试stun

STUN or TURN URI处写 stun:公网IP:3478,默认3478端口,不需要账号密码,Add Server后,点下面 Gather candidates,立即就会返回Done,表示成功,如果等待很久返回Done是失败,注意看是否防火墙没关闭或者云服务器端口没打开(UDP 3478)

image.png

2、测试turn,先生成用户名密码

拷贝下面直接执行得到用户名和密码

secret=mysecret && \
time=$(date +%s) && \
expiry=8400 && \
username=$(( $time + $expiry )) &&\
echo username:$username && \
echo password:$(echo -n $username | openssl dgst -binary -sha1 -hmac $secret | openssl base64)

image.png

STUN or TURN URI处写 turn:公网IP:3478,账号密码分别用上面生成的,Add Server, 点下面 Gather candidates,立即就会返回Done

image.png

janus 搭建

音视频时代,简单的音视频通话功能已经远远无法承载这个时代的更多需求,比如视频云录制、呼叫转移、视频流 AI 检测、视频流增强、信令暂存、和已有其他通信协议互相嫁接等等。这些庞大且涉及到复杂计算的能力,必须交给具有一定能力的服务来做,因此就有了WebRTC网关,Janus 就是其中一种开源且稳定更新的WebRTC网关。

Janus 相关地址

  • Janus 官网地址:官网
  • Janus 仓库地址:Github
  • Janus 非官方容器构建仓库 :Github

Janus 具有的基本功能

  • 回声测试、会议桥、媒体记录器、SIP 网关等基本功能。
  • 可插拔的,按需引入所需的功能,比如会议功能、p2p通信功能、录制功能、播放第三方媒体流、屏幕共享等等,要实现对应功能,可单独引入对应插件,因为 Janus 的设计架构就是插拔式的。
  • 自带用户统计,只需要按照特定的格式去请求即可,相当于给你提供了 WebSocket 服务器,你只需要按照规范来即可。
  • 使用json作为向服务器请求服务的参数,简洁。
  • 事件回调,接受自定义接口作为回调接口传回事件数据。

看上面的基本功能,我们可以很清晰地知道,有了 Janus,我们就无需自己实现信令服务器。

插件下载

mkdir -p /home/janus-docker/conf 
mkdir -p /home/janus-docker/ssl 
mkdir -p /home/janus-docker/record 
cd /home/janus-docker/conf 

# 下载下面配置文件 如果网络打不开可以尝试 github 替换为 github1s如上图 
https://github.com/meetecho/janus-gateway/blob/master/conf/janus.jcfg.sample.in 
https://github1s.com/meetecho/janus-gateway/blob/master/conf/janus.transport.http.jcfg.sample 
mv janus.jcfg.sample.in janus.jcfg 
mv janus.transport.http.jcfg.sample janus.transport.http.jcfg 
## 后面我们还会遇到各种插件同样的下载方法 最核心的就是上面俩个

image.png

image.png

docker-compose 文件创建以及路径挂载。

cd /home/janus-docker && touch docker-compose.yml
-------------------yaml文件内容配置-------------------------------
version: "3"
services:
  janus-gateway:
    image: 'sucwangsr/janus-webrtc-gateway-docker:20221018'
    command: ["/usr/local/bin/janus", "-F", "/usr/local/etc/janus"]
    network_mode: "host"
    volumes:
      - "/home/janus-docker/conf/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg" 
      - "/home/janus-docker/conf/janus.jcfg:/usr/local/etc/janus/janus.jcfg"
      - "/home/janus-docker/record:/home/janus-gateway/record"
      - "/home/janus-docker/ssl:/home/ssl"
    restart: always

修改基础配置。

janus.jcfg 中找下面条目配置修改,一定要注意找到对应的位置哦。

api_secret 为我们后面常用的重点配置,Rest API 的通行证。

##新版本中下面这几个路径在配置文件中是@@变量赋值,这里大家可以直接写成下面的
configs_folder = "/usr/local/etc/janus"                        
plugins_folder = "/usr/local/lib/janus/plugins"                   
transports_folder = "/usr/local/lib/janus/transports"     
events_folder = "/usr/local/lib/janus/events"                    
loggers_folder = "/usr/local/lib/janus/loggers"
-------------------------
api_secret = "sujanxxusrocks"  ## 客户端使用restApi用的token 请自行配置自己的(重点配置)
token_auth_secret = "sujanxxusrocks" ## 使用ws使用的token 请自行配置自己的
token_auth = true  ## 使用开启校验ws
admin_secret = "suaanusoverlord"  #管理员 请自行配置自己的
---------------
media: {
        #ipv6 = true
        #min_nack_queue = 500
        rtp_port_range = "17001-19001" ##  请开放公网服务器的安全组(UDP)
        #dtls_mtu = 1200
        #no_media_timer = 1
        #slowlink_threshold = 4
        #twcc_period = 100
        #dtls_timeout = 500
。。。。。
}
-----------------
nat: {
   stun_server="175.178.1.249", ## 需要配置coturn服务器ip或域名,否则会报错 ice failed
   stun_port = 3478  ## 默认stun端口
}

janus.transport.http.jcfg 配置

general: {                                                
        base_path = "/janus"      #基础路径
        http = true            # http开启  
        port = 18088            #http端口                              
        https = false           #https是否启用配置;启用的话后面就要配置ssl证书。                  

启动。

cd /home/janus-docker 
docker-compose up -d
## 启动完毕后查看日志
docker-compose logs -f

image.png

JS连接

"webrtc-adapter": "^8.2.0" 
--------- 
npm i webrtc-adapter -S

注意:按照刚才docker janus搭建,防火墙需要打开udp 17001-19001 和 tcp 18088 端口

拷贝Janus原代码,github.com/meetecho/ja…

因为这不是模块化引用代码,需要手动把代码拷贝到Janus.js文件中,然后最后自己加上导出

// 对外暴露全局变量
export default Janus

项目中引入

初始化成功

image.png

服务器日志

image.png

index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div style="width: 98%;height: 95vh;margin-top: 20px;">
        <div :gutter="20" style="display: flex;flex-direction: row;justify-content: center;">
            <input style="width:130px;" placeholder="请输入用户名" id="username"></input>
            <button type="success" onclick="registerUser()">注册</button>
            <input style="width:130px;" placeholder="请输入被呼叫者用户名" id="targetUserName"></input>
            <button type="primary" onclick="call()">呼叫</button>
        </div>
        <div>
            <div class="streams">
                <video id="localDomId" style="object-fit: fill;" height="350px" width="50%" controls muted></video>
                <video id="remoteVideo" style="object-fit: fill;" height="350px" width="50%" controls></video>
            </div>
        </div>

        <div style="position: fixed;bottom: 220px;width: 100%;">
            <div>
                <button type="danger" size="mini" round onclick="hangup()">挂断</button>
                <button type="warning" size="mini" round onclick="record()">录制</button>
            </div>
        </div>
    </div>
    <script src="./main.js"></script>
</body>

</html>

index.js

import adapter from 'webrtc-adapter';
import Janus from "@/utils/Janus.js";

function getQueryVariable(variable) {
    var query = window.location.search.substring(1);
    var vars = query.split("&");
    for (var i = 0; i < vars.length; i++) {
        var pair = vars[i].split("=");
        if (pair[0] == variable) { return pair[1]; }
    }
    return (false);
}
let myJanus = null;
let videoCallPluginHandle = null;
var opaqueId = null
function initJanus() {
    Janus.init({
        debug: false,
        dependencies: Janus.useDefaultDependencies({
            adapter: adapter,
        }),
        callback: () => {
            if (!Janus.isWebrtcSupported()) {
                Janus.log("is not Supported Webrtc!");
                return;
            }
        },
    });
    //客户端唯一标识
    opaqueId = "videocall-" + Janus.randomString(12);
    console.log("opaqueId", opaqueId);
    // 注册:
    myJanus = new Janus({
        server: "http://175.178.1.249:18088/janus",
        apisecret: "sujanxxusrocks",
        success: function () {
            Janus.log("初始化成功");
            initVideoCallPlugin();
        },
        error: function (cause) {
            // Error, can't go on...
            Janus.log("初始化失败");
            Janus.log(cause);
        },
        destroyed: function () {
            // I should get rid of this
            Janus.log("destroyed");
        },
    });
}
function initVideoCallPlugin() {
    console.log("opaqueId", opaqueId);
    myJanus.attach({
        opaqueId: opaqueId,
        plugin: "janus.plugin.videocall",
        success: function (pluginHandle) {
            //插件初始化成功后 pluginHandle 就是全局句柄,通过 pluginHandle可以操作当前
            //会话的所有功能
            videoCallPluginHandle = pluginHandle;
            setInterval(() => {
                getBitrate()
            }, 1500)
            console.log("视频呼叫插件初始化成功", videoCallPluginHandle);
        },
        error: function (cause) {
            //插件初始化失败
            console.log(cause);
        },
        onmessage: function (msg, jsep) {
            //msg 交互信息包括挂断 接听等事件监听
            // jsep  协商信令
            onMessageForVideoCall(msg, jsep)

        },
        onlocaltrack: function (track, added) {
            // 本地媒体流发布后可以监听
            console.log("本地媒体", track, added)
            if (added === true) {
                setDomVideoTrick("localDomId", track)
            }

        },
        onremotetrack: function (track, mid, added) {
            // 远端媒体流
            console.log("远程媒体", track, mid, added)
            if (added === true) {
                setDomVideoTrick("remoteVideo", track)
            }
        },
        oncleanup: function () {
            // PeerConnection 关闭监听
            // 同时可以创建信的句柄(旧的可用)重新初始化
        },
        detached: function () {
            // PeerConnection 关闭监听
            // 同时可以创建信的句柄(旧的不可用)重新初始化
        }
    });
}

function getBitrate() {
    if (videoCallPluginHandle) {
        console.log(videoCallPluginHandle.getBitrate())
    }
}

function handleError(error) {
    // alert("摄像头无法正常使用,请检查是否占用或缺失")
    console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
}

function onMessageForVideoCall(msg, jsep) {
    console.log(" ::: Got a message :::", msg);
    var result = msg["result"];
    if (result) {
        if (result["list"]) {
            var list = result["list"];
            console.log("注册Peers", list)
        } else if (result["event"]) {
            var event = result["event"];
            if (event === 'registered') {
                console.log("注册成功", msg)
                messageNotify("注册成功")
                videoCallPluginHandle.send({ message: { request: "list" } });
            } else if (event === 'calling') {
                console.log("呼叫中")
                messageNotify("呼叫中,请稍后")
            } else if (event === 'incomingcall') {
                let username = result["username"]
                console.log("来自于 【" + username + "】的呼叫")
                videoCallPluginHandle.createAnswer({
                    jsep: jsep,
                    tracks: [
                        { type: 'audio', capture: true, recv: true },
                        { type: 'video', capture: true, recv: true },
                        { type: 'data' },
                    ],
                    success: function (jsep) {
                        Janus.debug("应答 SDP!", jsep);
                        var body = { request: "accept" };
                        videoCallPluginHandle.send({ message: body, jsep: jsep });
                    },
                    error: function (error) {
                        console.error("创建应答异常", error)
                    }
                });
            } else if (event === 'accepted') {
                console.log("对方已接听同时设置协商信息", jsep)
                if (jsep) {
                    videoCallPluginHandle.handleRemoteJsep({ jsep: jsep });
                }
                messageNotify("对方已接听")
            } else if (event === 'update') {
                // An 'update' event may be used to provide renegotiation attempts
                if (jsep) {
                    if (jsep.type === "answer") {
                        videoCallPluginHandle.handleRemoteJsep({ jsep: jsep });
                    } else {
                        videoCallPluginHandle.createAnswer({
                            jsep: jsep,
                            tracks: [
                                { type: 'audio', capture: true, recv: true },
                                { type: 'video', capture: true, recv: true },
                                { type: 'data' },
                            ],
                            success: function (jsep) {
                                console.log("重新应答信令 SDP!", jsep);
                                var body = { request: "set" };
                                videoCallPluginHandle.send({ message: body, jsep: jsep });
                            },
                            error: function (error) {
                                console.error(error)
                            }
                        });
                    }
                }
            } else if (event === 'hangup') {
                console.log(result["username"] + "已挂断,原因:(" + result["reason"] + ")!");
                videoCallPluginHandle.hangup();
                messageNotify("已挂断")
                clearMedia()
            } else if (event === "simulcast") {
                console.log("联播simulcast,暂时不用考虑", msg)
            }
        }
    } else {
        // 出错
        var error = msg["error"];
        console.log("未知异常", msg)
        messageNotify(error)
        //挂断
        videoCallPluginHandle.hangup();
    }
}
function messageNotify(msg) {
    console.log('messageNotify', msg)
}
function clearMedia() {
    let local = document.getElementById('localDomId')
    if (local && local.srcObject) {
        local.srcObject.getTracks().forEach(e => {
            e.stop()
        })
        local.srcObject = null
    }
    let remote = document.getElementById('remoteVideo')
    if (remote && remote.srcObject) {
        remote.srcObject.getTracks().forEach(e => {
            e.stop()
        })
        remote.srcObject = null
    }
    window.audioStatus = true
    window.videoStatus = true
}
function setDomVideoTrick(domId, trick) {
    let video = document.getElementById(domId)
    let stream = video.srcObject
    if (stream) {
        stream.addTrack(trick)
    } else {
        stream = new MediaStream()
        stream.addTrack(trick)
        video.srcObject = stream
        video.controls = false;
        video.autoplay = true;
        // video.muted = false
        // video.style.width = '100%'
        // video.style.height = '100%'
    }
}
/**
 * @author suc
 * device list init 
 */
var localDevice = {
    audioIn: [],
    videoIn: [],
    audioOut: []
}
var formInline = {
    videoId: undefined,
    audioInId: undefined,
    audioOutId: undefined
}
function initInnerLocalDevice() {
    // 准备两个摄像头,浏览器中设置不同摄像头使用
    let constraints = { video: true, audio: true }
    if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
        console.log("浏览器不支持获取媒体设备");
        return;
    }
    navigator.mediaDevices.getUserMedia(constraints)
        .then(function (stream) {
            stream.getTracks().forEach(trick => {
                trick.stop()
            })

            // List cameras and microphones.
            navigator.mediaDevices.enumerateDevices()
                .then(function (devices) {
                    devices.forEach(function (device) {
                        let obj = { id: device.deviceId, kind: device.kind, label: device.label }
                        if (device.kind === 'audioinput') {
                            if (localDevice.audioIn.filter(e => e.id === device.deviceId).length === 0) {
                                localDevice.audioIn.push(obj)
                            }
                        } if (device.kind === 'audiooutput') {
                            if (localDevice.audioOut.filter(e => e.id === device.deviceId).length === 0) {
                                localDevice.audioOut.push(obj)
                            }
                        } else if (device.kind === 'videoinput') {
                            if (localDevice.videoIn.filter(e => e.id === device.deviceId).length === 0) {
                                localDevice.videoIn.push(obj)
                            }
                        }
                    });
                })
                .catch(handleError);

        })
        .then(() => {
            console.log('本地设备', localDevice)

        })
        .catch(handleError);
}

initInnerLocalDevice()
initJanus();
window.registerUser = function () {
    const username = document.getElementById('username').value
    if (!username) {
        alert("请输入用户名")
        return
    }
    if (!videoCallPluginHandle) {
        alert("视频插件还未初始化")
        return
    }
    var register = { request: "register", username: username };
    videoCallPluginHandle.send({ message: register });
}
window.call = function () {
    const targetUserName = document.getElementById('targetUserName').value
    videoCallPluginHandle.createOffer({
        //双向语音视频+datachannel
        tracks: [
            { type: 'audio', capture: true, recv: true },
            { type: 'video', capture: true, recv: true, simulcast: false },
            { type: 'data' },
        ],
        success: function (jsep) {
            Janus.debug("呼叫端创建 SDP信息", jsep);
            var body = { request: "call", username: targetUserName };
            videoCallPluginHandle.send({ message: body, jsep: jsep });
        },
        error: function (error) {
            console.error("呼叫异常", error)
        }
    });
}
window.hangup = function () {
    alert(1)
}
window.record = function () {
    alert(1)
}
window.controlVideo = function () {
    window.videoStatus = !window.videoStatus
    document.getElementById('controlVideotrue').style.display = 'none'
    document.getElementById('controlVideofalse').style.display = 'none'
    document.getElementById('controlVideo' + window.videoStatus).style.display = 'block'
    videoCallPluginHandle.send({
        message:
            { request: "set", video: window.videoStatus },
    });
}
window.controlAudio = function () {
    window.audioStatus = !window.audioStatus
    document.getElementById('controlAudiotrue').style.display = 'none'
    document.getElementById('controlAudiofalse').style.display = 'none'
    document.getElementById('controlAudio' + window.audioStatus).style.display = 'block'
    videoCallPluginHandle.send({
        message:
            { request: "set", audio: window.audioStatus },
    });
}

srs 流媒体服务器

考虑到便捷性,我们使用容器化来部署。

// 1935 RTMP的常用端口  1985 API接口端口  8080默认控制台访问端口 在这里我映射到宿主机8085端口
docker run 
						

上一篇: 如何在 Ubuntu Server 上启用 ipvs 模块?

下一篇: 理解并比较LVS与Keepalived的作用