mthoutai

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

1 STUN/TURN 服务器搭建

1.1 STUN/TURN 服务器选型

rfc5766-turn-server
coTurn
ResTurn

1.2 coTurn 服务器搭建与布署

下载 coTurn
https://github.com/coturn/coturn
./configure --prefix=/usr/local/coturn
make -j 4
sudo make install

1.3 coTurn 服务器配置

1.4 启动 coTurn 服务器

ps -ef | grep turn
/usr/local/coturn/bin/turnserver -c /usr/local/coturn/etc/turnserver.conf.default
https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

2 RTCPeerConnection

2.1 基本格式

pc = new RTCPeerConnection([configuration]);

2.2 configuration 可选

2.2.1 bundlePolicy

Balanced: 音频与视频轨使用各自的传输通道
max-compat: 每个轨使用自己的传输通道
max-bundle: 都绑定到同一个传输通道

2.2.2 certificates

授权可以使用连接的一组证书

2.2.3 iceCandidatePoolSize

16位的整数值,用于指定预取的ICE候选者的个数。
如果该值发生变化,它会解发重新收集候选者

2.2.4 iceTransportPolicy

指定ICE传输策略
relay: 只使用中继候选者
all: 可以使用任何类型的候选者

2.2.5 iceServers

其由 RTCIceServer组成。
每个RTCIceServer都是一个ICE代理的服务

2.2.6 rtcpMuxPolicy

该选项在收集ICE候选者的使用

2.3 addIceCandidate

2.3.1 基本格式

aPromise=pc.addIceCandidate(candidate);

2.3.2 candidate

3 真正的音视频传输

3.1 客户端信令消息

join 加入房间
leave 离开房间
message 端到端消息

3.2 端到端信令消息

Offer 消息
Answer 消息
Candidate 消息

3.3 服务端信令消息

joined 已加入房间
otherjoin 其它用户加入房间
full 房间人数已满
leaved 已离开房间
bye 对方已离开房间

3.4 直播系统消息处理流程

4 信令服务器改造

'use strict'
var http = require('http');
var https = require('https');
var fs = require('fs');
var express = require('express');
var serveIndex = require('serve-index');
var socketIo = require('socket.io');
var USERCOUNT = 3;
var log4js = require('log4js');
log4js.configure({
    appenders: {
        console: {
            type: 'console'
        },
        log: {
            type: 'file',
            filename: 'log.log',
            maxLogSize: 1024,
            backups: 3,
            compress: true
        }
    },
    categories: {
        default: {
            appenders: ['console', 'log'],
            level: 'debug'
        }
    }
})
var logger = log4js.getLogger();
var app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'))
var http_sever = http.createServer(app);
http_sever.listen(80, '0.0.0.0');
var options = {
    key: fs.readFileSync('./cert/key.pem'),
    cert: fs.readFileSync('./cert/cert.pem')
};
var https_server = https.createServer(options, app);
var io = socketIo.listen(https_server);
io.sockets.on('connection', (socket)=> {
    console.log('connection');
    socket.on('message', (room, data)=> {
        socket.to(room).emit('message', room, data);
    });
    socket.on('join', (room)=> {
        socket.join(room);
        var my_room = io.sockets.adapter.rooms[room];
        var users = my_room ? Object.keys(my_room.sockets).length:0;
        logger.debug('the number of user in room is: ' + users);
        if (users < USERCOUNT){
            socket.emit('joined', room, socket.id);
            if (users > 1){
                socket.to(room).emit('othersjoin', room);
            }
        }else{
            socket.leave(room);
            socket.emit('full', room, socket.id);
        }
        // socket.emit('joined', room, socket.id);
        //socket.to(room).emit('joined', room, socket.id); // 除自己之外 所有 人
        // io.in(room).emit('joined', room, socket.id); // 房间内所有人
        //socket.broadcast.emit('joined', room, socket.id); // 除自己之外 ,全部 站点
    });
    socket.on('leave', (room)=> {
        var my_room = io.sockets.adapter.rooms[room];
        var users = my_room ? Object.keys(my_room.sockets).length : 0;
        logger.debug('the number of user in room is: ' + (users - 1));
        socket.to(room).emit('bye', room, socket.id);
        socket.emit('leaved', room, socket.id);
        // socket.emit('joined', room, socket.id);
        // socket.to(room).emit('joined', room, socket.id); // 除自己之外 所有 人
        // io.in(room).emit('joined', room, socket.id); // 房间内所有人
        //socket.broadcast.emit('joined', room, socket.id); // 除自己之外 ,全部 站点
    });
});
https_server.listen(443, '0.0.0.0');

5 再论 CreateOffer

5.1 基本格式

aPromise=myPeerConnection.createOffer([optionss]);

5.2 Options 可选

iceRestart: 该选项会重启ICE,重新进行 Candidate 收集
voiceActivityDetection: 是否开启静音检测,默认开启

5.3 CreateOffer 实战

接收远端音频
接收远端视频
静音检测
ICE restart

6 直播客户端的实现

6.1 客户端状态机

6.2 客户端状流程图

6.3 端对端连接的基本流程

6.4 直播客户端的实现

6.4.1 注意要点

网络连接要在音视频数据获取之后,否则有可能绑定音视频流失败
当一端退出房间后,另一端的 PeerConnection 要关闭重建,否则与新用户互通时媒体协商会失败
异步事件处理

    
        WebRTC PeerConnection
		
    
    
        

Local:

Remote:

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script> <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> <script src="./js/main.js"></script>
'use strict'
var localVideo = document.querySelector('video#localvideo');
var remoteVideo = document.querySelector('video#remotevideo');
var btnConn = document.querySelector('button#connserver');
var btnLeave = document.querySelector('button#leave');
var btnStrat = document.querySelector('button#start');
var btnCall = document.querySelector('button#call');
var btnHangup = document.querySelector('button#hangup');
var offer = document.querySelector('textarea#offer');
var answer = document.querySelector('textarea#answer');
var localStream;
var roomid;
var socktet = null;
var state = 'init';
var pc = null;
var pc1;
var pc2;
var roomid = '111';
function sendMessage(roomid, data) {
    console.log('send message: ', roomid, data);
    if (!socktet){
        return;
    }
    socktet.emit('message', roomid, data);
}
function handleAnswerError(error) {
    console.log('handleAnswerError: ', error);
}
function getAnswer(desc) {
    pc.setLocalDescription(desc);
    sendMessage(roomid, desc);
    //answer.value = desc.sdp;
}
function getOffer(desc) {
    pc.setLocalDescription(desc);
    sendMessage(roomid, desc);
}
function handleOfferError(error) {
    console.log('handleOfferError: ', error);
}
function call() {
    if (state == 'joined_conn'){
        if (pc){
            var optons = {
                offerToReceiveAudio: true,
                offerToReceiveVideo: true
            };
            pc.createOffer(optons)
            .then(getOffer)
            .catch(handleOfferError);
        }
    }
}
function connectServer() {
    start();
    return true;
}
function closeLocalMedia(){
    if (localStream && localStream.getTracks()){
        localStream.getTracks().forEach(function(track) {
            track.stop();
        });
    }
    localStream = null;
}
function conn() {
    socktet = io.connect();
    socktet.on('joined', function(room, id) {
        console.log('Joined room ' + room + ' as ' + id);
        state = 'joined';
        createPeerConnection();
        btnConn.disabled = true;
        btnLeave.disabled = false;
        console.log('state: ', state);
    });
    socktet.on('otherjoin', function(room, id) {
        console.log('Other join room ' + room + ' as ' + id);
        if (state == 'joined_unbind'){
            createPeerConnection();
        }
        state = 'joined_conn';
        console.log('state: ', state);
    });
    socktet.on('full', function(room, id) {
        console.log('full Room ' + room + ' is full');
        state = 'leaved';
        console.log('state: ', state);
        socktet.disconnect();
        alert('the room is full');
        btnConn.disabled = false;
        btnLeave.disabled = true;
    });
    socktet.on('leaved', function(room, id) {
        console.log('Leaved room ' + room + ' as ' + id);
        state = 'leaved';
        console.log('state: ', state);
        socktet.disconnect();
        btnConn.disabled = false;
        btnLeave.disabled = true;
    });
    socktet.on('bye', function(room, id) {
        console.log('bye room ' + room + ' as ' + id);
        state = 'joined_unbind';
        closePeerConnection();
        console.log('state: ', state);
        btnConn.disabled = false;
        btnLeave.disabled = true;
    });
    socktet.on('message', function(room, data) {
        console.log('message room ' + room + ' data ' + data);
        state = 'message';
        if (data){
            if(data.type == 'offer'){
                pc.setRemoteDescription(new RTCSessionDescription(data));
                pc.createAnswer()
                .then(getAnswer)
                .catch(handleAnswerError);
            }else if(data.type == 'answer'){
                pc.setRemoteDescription(new RTCSessionDescription(data));
            }else if(data.type == 'candidate'){
                var candidate = new RTCIceCandidate({
                    sdpMLineIndex: data.label,
                    candidate: data.candidate
                });
                pc.addIceCandidate(candidate);
            }
            else{
                console.error('unknown message type: ', data.type);
            }
        }
    });
    socktet.emit('join', '111');
    return;
}
function getMediaStream(stream) {
    localVideo.srcObject = stream;
    localStream = stream;
    conn();
}
function handleError(error) {
    console.log('Error: ', error);
}
function start() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
    console.log('getUserMedia not supported');
    return;
  }else{
    var constraints = {
        video: true,
        audio: true
    };
    navigator.mediaDevices.getUserMedia(constraints)
    .then(getMediaStream)
    .catch(handleError);
}
}
function leave() {
    if (socktet){
        socktet.emit('leave', '111');
    }
    closePeerConnection();
    closeLocalMedia();
    btnConn.disabled = false;
    btnLeave.disabled = true;
}
function createPeerConnection() {
    console.log('createPeerConnection');
    if (!pc){
        var pcConfig = {
            iceServers: [
                {
                    //urls: 'stun:stun.l.google.com:19302'
                    urls: 'stun:192.168.3.5:3478',
                    username: 'username1',
                    credential: 'key1'
                }
            ]
        };
        pc = new RTCPeerConnection(pcConfig);
        pc.onicecandidate = function(event) {
            if (event.candidate) {
                console.log('icecandidate: ', event.candidate);
                sendMessage(roomid, {
                    type: 'candidate',
                    label: event.candidate.sdpMLineIndex,
                    id: event.candidate.sdpMid,
                    candidate: event.candidate.candidate
                });
            }
        };
        pc.ontrack = (event)=>{
            console.log('ontrack: ', event);
            remoteVideo.srcObject = event.streams[0];
        };
    }
    if (localStream){
        localStream.getTracks().forEach(function(track) {
            pc.addTrack(track, localStream);
        });
    }
}
function closePeerConnection() {
    console.log('closeRTCPeerConnection');
    if (pc){
        pc.close();
        pc = null;
    }
}
btnConn.onclick = connectServer;
btnLeave.onclick = leave;

6.5 远程桌面

getDisplayMedia()
var promise=navigator.mediaDevices.getDisplayMedia(constraints);
constraints:
constraints 中约束与 getUserMedia 函数中的基本一致
getDisplayMedia 无法同时采集音频
桌面是否可以调整分辨率
共享整个桌面
共享其中一个应用
共享区域

7 RTP Media

7.1 Receiver and Sender

getReceivers()
获得一组 RTCRtpReceiver 对象,用于接收数据
getSenders()
获得一组 RTCRtpSender 对象,用于发送数据,每个对象对应一个媒体轨

7.2 RTCRtpReceiver 和 RTCRtpSender 共同属性

7.3 RTCRtpReceiver

7.4 RTCRtpSender 

7.5 RTP Media 结构体

7.5.1 RTCRtpTransceiver

getTransceivers()
从 PC 获得一组 RTCRtpTransceiver 对象,每个 RTCRtpTransceiver 是 RTCRtpSender 和RTCRtpReciver 对
方法:
stop : 停止发送和接收媒体数据

7.6 控制传输速率

7.7 WebRTC 统计信息 

8 非音视频数据传输

8.1 createDataChannel

var aPromise=pc.createDataChannel(label,[options]);
label : 人类可读的字符串
options : 可选
ordered
maxPacketLifeTime
maxRetransmits
negotiated :
如果为 false,一端使用 createDataChannel创建通道,另一端监听 ondatachannel 事件
如要为 true, 两端都可以调用 createDataChannel创建通道,另一端通过 id 来标识同一通道
id
var ch = pc.createDataChannel(
    "chat",
    {negotiated:true, id:0}
);
DataChannel事件:
onmessage();
onopen();
onclose();
onerror();
var pc = new RTCPeerConnection();
var dc = pc.createDataChannel("dc", options);
dc.onerror = (error)=> {
};
dc.onopen = ()=> {
};
dc.onclose = ()=> {
};
dc.onmessage = (event)=> {
};

8.2 非音视频数据传输方式

8.3 实时文本聊天

8.4 实时传输文件

9 协议规范

9.1 协议栈

9.2 WebRTC 传输协议

9.2.1 RTP/SRTP

RTP字段说明:
V : 2位,RTP版本号
P : 1位,填充标识,最后一个填充字节是填充字节(含自己)个数
X : 1位,扩展标识,
CC : 4位,CSRC个数
M : 1位,标识帧边界
PT : 7位,playload type,用于区分不同编解码器的数据
seq number : 16位,标识包的顺序
timestamp : 32位,时间戳
SSRC : 32位,每个视频源使用一个 SSRC
CSRC : 32位,多路混音,混流时使用

9.2.2 RTCP/SRTCP

9.2.2.1 RTCP Payload Type
PT缩写说明
200SRSender Report packet
201RRReceiver Report packet
202SDESSource Description packet
203BYEGoodbye packet
204APPApplication-defined packet

9.2.2.2 RTCP Header

RTCP Header 字段说明:
p : 1位,填充标识,最后一个填充字节是填充字节(含自己)个数
RC : 5位,接收报告块的个数,可以是 0
PT : 8位,数据包类型
length : 16位,包长度(包括头和填充)。(N-1)个4字节
SSRC : 32位,发送者的 SSRC
9.2.2.3 RTCP Sender Report
Sender Information block

Sender Info 字段说明:
NTP : 64位,网络时间戳,用于不同源之间的同步
RTP timestamp : 32位,相对时间戳,与RTP包时间戳一致
packet count : 32位,总发包数,SSRC变化时被重置
octet count : 32位,总共发送的字节数
Receive Report block 字段说明:
SSRC_n : 32位,接收到的每个媒体源,n表式第几个
fraction lost : 8位,上一次报告之后到本次报告间的丢包比例
packets lost : 24位,自接收开始丢包总数,迟到包不算丢包。
hightest seq num : 低16位表式收到的最大seq,高16位表式seq循环次数
jitter : 估算的RTP包到达时间间隔的统计方差
Last SR : 32位,上一次 Sender Report 的NTP时间戳
Delay LSR : 记录接收SR的时间与这次SR的时间差
9.2.2.4 RTCP Receiver Report
Receiver Report block 字段说明:
SSRC_n : 32位,接收到的每个媒体源,n表式第几个
fraction lost : 8位,上一次报告之后到本次报告间的丢包比例2
packets lost : 4位,自接收开始丢包总数,迟到包不算丢包。
hightest seq num : 低16位表式收到的最大seq,高16位表式seq循环次数
jitter : 估算的RTP包到达时间间隔的统计方差
Last SR : 32位,上一次 Sender Report 的NTP时间戳
Delay LSR : 记录接收SR的时间与这次SR的时间差

9.2.2.5 RTCP SR/RR 发送时机

接收端只发送 RR
既是发送端又是接收端时,在上一次报告之后有发送过数据时,则发 SR
9.2.2.6 RTCP SDES

9.2.2.6.1  SDES item

SDES 说明
SC SSRC/CSRC 数量
Item 采用 TLV 存放数据
CNAME SSRC 的规范名,在 RTP 会话中唯一
9.2.2.7 RTCP BYE
字段说明:
ST : 5位,应用自定义的在同一个名子下的子类型
name : 4字节,应用自定义的名子

9.2.3 DTLS

9.2.4 SRTP

9.3 WireShark 抓包分析

10 Android 与浏览器互通

10.1 获取权限

10.1.1 Camera 权限

10.1.2 录制 Audio 权限 

10.1.3 Internet 权限

10.1.4 Android 权限管理

申请静态权限

申请动态权限
void requestPermission(String[] permission, intrequestCode);

10.2 引入库 (WebRTC, socket.io)

WebRTC
implementation 'org.webrtc:google-webrtc:1.0.+'
socket.io
implementation 'io.socket:socket.io-client:1.0.0'
easypermission
implementation 'pub.devrel:easypermissions:1.1.3'

10.3 信令处理

10.3.1 客户端消息

join, leave, message(offer, answer, candidate)

10.3.2 服务端消息

joined, leaved, other_joined, bye, full

10.3.3 Android socket.io

发送消息:
socket.emit("join", agrs);
接收消息:
socket.on("joined", Listener);
private Emitter.Listener Listener = new Emitter.Listener(){
    @Override
    public void call(final Object... args){
        ...
    }
};

10.4 WebRTC处理流程

10.4.1 结构图

10.4.2 呼叫端时序图

10.4.3 被叫端时序图

10.4.4 关闭时序图

10.4.5 WebRTC处理流程

10.5 重要的类

10.5.1 PeerConnectionFactory

10.5.2 PeerConnection

10.5.3 MediaStream

10.5.4 VideoCapture

10.5.5 Video Source / Video Track

10.5.6 Audio Source / Audio Track

10.6 两个观察者

10.6.1 PeerConnection.Observer

10.6.2 SdpObserver

10.7 Android 与浏览器互通实例

10.7.1 权限、库与界面

10.7.1.1 在 Manifests 文件中添加权限
#AndroidManifest.xml




10.7.1.2 在 Module 级的 gradle 中引入信赖库
compileOptions {
    sourceCompatibility = '1.8'
    targetCompatibility = '1.8'
}
implementation 'com.infobip:google-webrtc:1.0.45036'
implementation 'io.socket:socket.io-client:1.0.0'
implementation 'pub.devrel:easypermissions:3.0.0'
10.7.1.3 编写界面

10.8 收发信令

10.8.1 实现 Activity 的切换

10.8.2 编写 signal 类使用 socket.io 收发信令

10.8.3 在CallActivity 中使用signal 对象

10.9 创建 PeerConnection

10.9.1 音视频数据采集

10.9.2 创建 PeerConnection

10.9.3 协商媒体能力

10.9.4 Candidate 连通

10.9.5 视频渲染

posted on 2025-11-08 17:22  mthoutai  阅读(96)  评论(0)    收藏  举报