[*] mesh多人

This commit is contained in:
acgist
2022-12-03 14:02:11 +08:00
parent 18cc4e536d
commit 7f5ee58fbb
20 changed files with 197 additions and 218 deletions

View File

@@ -9,7 +9,6 @@
|taoyao|桃夭|桃之夭夭灼灼其华|
|taoyao-boot|基础|基础模块|
|taoyao-live|直播|直播、连麦、本地视频同看|
|taoyao-test|测试|测试模块|
|taoyao-media|媒体|录制<br />音频(降噪、混音、变声)<br />视频水印、美颜、AI识别|
|taoyao-signal|信令|信令服务|
|taoyao-server|服务|启动服务|
@@ -29,9 +28,9 @@
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| taoyao-media |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| taoyao-moon | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ taoyao-mesh +
| taoyao-kurento | |
| taoyao-webrtc-moon | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ taoyao-webrtc-mesh +
| taoyao-webrtc-kurento | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| taoyao-signal |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
@@ -50,9 +49,9 @@
#### 功能简介
* ~~直播~~
* 会议:一对一、~~多对多~~
* ~~媒体:降噪、变声、美颜录制、等等~~
* 可能需要自己搭建`coturn`服务实现`STUN`/`TURN`内网穿透功能
* 可能需要自己搭建`coturn`服务实现`STUN`/`TURN`功能
* 终端和终端之间各自建立一个独立媒体连接
### Moon
@@ -65,3 +64,4 @@
* 需要安装[KMS服务](./docs/Deploy.md#kmskurento-media-server)
* 提供混音、变声、美颜、录制等等媒体功能
* 终端推送给服务端最高质量媒体,再由服务端根据订阅终端按配置分流。
* 终端和服务器之间建立两个媒体连接,一个本地媒体,一个远程媒体。

View File

@@ -1,32 +0,0 @@
@startuml
title WebRTC-Mesh
actor ClientA as ClientA
participant "Signal" as Signal
actor ClientB as ClientB
actor ClientC as ClientC
autonumber
ClientA -> Signal: 进入房间
activate ClientA
activate Signal
Signal -> ClientB: ClientA进入房间
activate ClientB
ClientB -> Signal: 订阅ClientA
Signal -> ClientA: ClientB订阅ClientA
ClientA -> Signal: ClientA发布ClientB
Signal -> ClientB: ClientA发布
deactivate ClientB
Signal -> ClientC: ClientA进入房间
activate ClientC
ClientC -> Signal: 订阅ClientA
Signal -> ClientA: ClientC订阅ClientA
ClientA -> Signal: ClientA发布ClientC
Signal -> ClientC: ClientA发布
deactivate ClientC
deactivate Signal
deactivate ClientA
@enduml

10
pom.xml
View File

@@ -37,7 +37,6 @@
<modules>
<module>taoyao-boot</module>
<module>taoyao-live</module>
<module>taoyao-test</module>
<module>taoyao-media</module>
<module>taoyao-signal</module>
<module>taoyao-server</module>
@@ -113,11 +112,6 @@
<artifactId>taoyao-live</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.acgist</groupId>
<artifactId>taoyao-test</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.acgist</groupId>
<artifactId>taoyao-media</artifactId>
@@ -125,12 +119,12 @@
</dependency>
<dependency>
<groupId>com.acgist</groupId>
<artifactId>taoyao-signal</artifactId>
<artifactId>taoyao-server</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.acgist</groupId>
<artifactId>taoyao-server</artifactId>
<artifactId>taoyao-signal</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>

View File

@@ -8,3 +8,6 @@
:: Spring Boot : ${spring-boot.formatted-version}
:: ${spring.application.name} : https://gitee.com/acgist/taoyao
中庭地白树栖鸦,冷露无声湿桂花。
今夜月明人尽望,不知秋思落谁家。

View File

@@ -65,7 +65,7 @@ public class MeetingManager {
*/
public Meeting create(String sn) {
final Meeting meeting = new Meeting();
meeting.setId(this.idService.buildIdToString());
meeting.setId("1");
meeting.setSns(new CopyOnWriteArrayList<>());
meeting.setCreator(sn);
meeting.addSn(sn);

View File

@@ -30,12 +30,6 @@
<groupId>com.acgist</groupId>
<artifactId>taoyao-meeting</artifactId>
</dependency>
<dependency>
<groupId>com.acgist</groupId>
<artifactId>taoyao-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -91,19 +91,19 @@ taoyao:
# 媒体端口范围
min-port: 45535
max-port: 65535
# 公共服务
stun:
- stun:stun1.l.google.com:19302
- stun:stun2.l.google.com:19302
- stun:stun3.l.google.com:19302
- stun:stun4.l.google.com:19302
- stun:stun.stunprotocol.org:3478
# 自己搭建coturn
turn:
- stun:stun1.l.google.com:19302
- stun:stun2.l.google.com:19302
- stun:stun3.l.google.com:19302
- stun:stun4.l.google.com:19302
- stun:stun.stunprotocol.org:3478
# KMS服务配置
- turn:127.0.0.1:8888
- turn:127.0.0.1:8888
- turn:127.0.0.1:8888
- turn:127.0.0.1:8888
# KMS服务配置可以部署多个简单实现负载均衡
kms:
host: 192.168.1.100
port: 18888

View File

@@ -60,7 +60,7 @@ const defaultRPCConfig = {
/** 信令配置 */
const signalConfig = {
/** 当前终端SN */
sn: localStorage.getItem('taoyao.sn') || 'taoyao',
sn: 'taoyao',
/** 当前版本 */
version: '1.0.0',
// 信令授权
@@ -170,6 +170,7 @@ const signalChannel = {
clearTimeout(self.heartbeatTimer);
}
self.heartbeatTimer = setTimeout(function() {
// 电池navigator.getBattery()
if (self.channel && self.channel.readyState === WebSocket.OPEN) {
self.push(signalProtocol.buildProtocol(
signalProtocol.client.heartbeat,
@@ -341,9 +342,23 @@ const signalChannel = {
},
/** 终端默认回调 */
defaultClientConfig: function(data) {
this.taoyao
let self = this;
// 配置终端
self.taoyao
.configMedia(data.body.media.audio, data.body.media.video)
.configWebrtc(data.body.webrtc);
// 打开媒体通道
let videoId = self.taoyao.videoId;
if(videoId) {
self.taoyao.buildLocalMedia()
.then(stream => {
self.taoyao.buildMediaChannel(videoId, stream);
})
.catch(e => console.error('打开终端媒体失败', e));
console.debug('自动打开媒体通道', videoId);
} else {
console.debug('没有配置本地媒体信息跳过自动打开媒体通道');
}
},
defaultClientReboot: function(data) {
console.info('重启终端');
@@ -355,10 +370,10 @@ const signalChannel = {
defaultMediaSubscribe: function(data) {
let self = this;
const from = data.body.from;
this.taoyao.remoteClientFilter(from, true);
self.taoyao.localMediaChannel.createOffer().then(description => {
const remote = this.taoyao.remoteClientFilter(from, true);
remote.localMediaChannel.createOffer().then(description => {
console.debug('Local Create Offer', description);
self.taoyao.localMediaChannel.setLocalDescription(description);
remote.localMediaChannel.setLocalDescription(description);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.offer,
{
@@ -374,11 +389,11 @@ const signalChannel = {
defaultMediaOffer: function(data) {
let self = this;
const from = data.body.from;
this.taoyao.remoteClientFilter(from, true);
self.taoyao.remoteMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body.sdp));
self.taoyao.remoteMediaChannel.createAnswer().then(description => {
const remote = this.taoyao.remoteClientFilter(from, true);
remote.remoteMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body.sdp));
remote.remoteMediaChannel.createAnswer().then(description => {
console.debug('Remote Create Answer', description);
self.taoyao.remoteMediaChannel.setLocalDescription(description);
remote.remoteMediaChannel.setLocalDescription(description);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.answer,
{
@@ -392,18 +407,27 @@ const signalChannel = {
});
},
defaultMediaAnswer: function(data) {
this.taoyao.localMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body.sdp));
const from = data.body.from;
const remote = this.taoyao.remoteClientFilter(from, true);
remote.localMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body.sdp));
},
defaultMediaCandidate: function(data) {
if(!this.taoyao.checkCandidate(data.body.candidate)) {
console.debug('候选缺失要素', data);
return;
}
console.debug('Set ICE Candidate', this.taoyao.remoteMediaChannel);
console.debug('Set ICE Candidate', data.body);
const from = data.body.from;
const remote = this.taoyao.remoteClientFilter(from, true);
if(data.body.type === 'local') {
this.taoyao.remoteMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
remote.remoteMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
} else if(data.body.type === 'remote'){
remote.localMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
} else if(data.body.type === 'mesh') {
remote.localMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
// remote.remoteMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
} else {
this.taoyao.localMediaChannel.addIceCandidate(new RTCIceCandidate(data.body.candidate));
console.warn('不支持的候选类型', data.body.type);
}
},
/** 会议默认回调 */
@@ -413,10 +437,14 @@ const signalChannel = {
};
/** 终端 */
function TaoyaoClient(
taoyao,
sn,
shareMediaChannel,
audioEnabled,
videoEnabled
) {
/** 桃夭 */
this.taoyao = taoyao;
/** 终端标识 */
this.sn = sn;
/** 视频对象 */
@@ -429,6 +457,12 @@ function TaoyaoClient(
this.audioStatus = false;
this.videoStatus = false;
this.recordStatus = false;
/** 本地媒体通道 */
this.localMediaChannel = null;
/** 远程媒体通道 */
this.remoteMediaChannel = null;
/** 是否共享媒体通道 */
this.shareMediaChannel = shareMediaChannel;
/** 媒体状态:是否播放 */
this.audioEnabled = audioEnabled == undefined ? true : audioEnabled;
this.videoEnabled = videoEnabled == undefined ? true : videoEnabled;
@@ -453,6 +487,7 @@ function TaoyaoClient(
/** 关闭视频 */
this.close = async function() {
await this.video.close();
// TODO释放连接
return this;
};
/** 设置媒体 */
@@ -474,15 +509,21 @@ function TaoyaoClient(
if(track.kind === 'video') {
this.buildVideoTrack(track);
}
} else {
} else if(stream) {
let audioTrack = stream.getAudioTracks();
let videoTrack = stream.getVideoTracks();
// TODO验证API试试修改媒体
// audioTrack.getSettings
// audioTrack.getCapabilities
// audioTrack.applyCapabilities
if(audioTrack && audioTrack.length) {
audioTrack.forEach(v => this.buildAudioTrack(v));
}
if(videoTrack && videoTrack.length) {
videoTrack.forEach(v => this.buildVideoTrack(v));
}
} else {
throw new Error('无效媒体信息');
}
console.debug('设置媒体', this.video, this.stream, this.audioTrack, this.videoTrack);
await this.load();
@@ -509,13 +550,53 @@ function TaoyaoClient(
this.stream.addTrack(track);
}
};
/** 打开媒体通道 */
this.openMediaChannel = function() {
if(this.shareMediaChannel) {
this.localMediaChannel = this.taoyao.localMediaChannel;
this.remoteMediaChannel = this.taoyao.remoteMediaChannel;
} else {
let self = this;
// 本地通道
let mediaChannel = new RTCPeerConnection(defaultRPCConfig);
self.taoyao.localClient.audioTrack.forEach(v => mediaChannel.addTrack(v, self.taoyao.localClient.stream));
self.taoyao.localClient.videoTrack.forEach(v => mediaChannel.addTrack(v, self.taoyao.localClient.stream));
mediaChannel.ontrack = function(e) {
console.debug('Mesh Media Track', self.sn, e);
let remote = self.taoyao.remoteClientFilter(self.sn);
remote.buildStream(remote.sn, e.streams[0], e.track);
};
mediaChannel.onicecandidate = function(e) {
// TODO判断给谁
let to = self.taoyao.remoteClient.map(v => v.sn)[0];
if(!self.taoyao.checkCandidate(e.candidate)) {
console.debug('Send Mesh ICE Candidate Fail', e);
return;
}
console.debug('Send Mesh ICE Candidate', to, e);
self.taoyao.push(signalProtocol.buildProtocol(
signalProtocol.media.candidate,
{
to: to,
type: 'mesh',
candidate: e.candidate
}
));
};
this.localMediaChannel = mediaChannel;
this.remoteMediaChannel = mediaChannel;
}
};
}
/** 桃夭 */
function Taoyao(
videoId,
webSocket,
localClientAudioEnabled,
localClientVideoEnabled
) {
/** 本地视频ID */
this.videoId = videoId;
/** WebRTC配置 */
this.webrtc = null;
/** WebSocket地址 */
@@ -538,6 +619,8 @@ function Taoyao(
/** 远程终端 */
this.remoteClient = [];
this.remoteMediaChannel = null;
/** 是否共享媒体通道 */
this.shareMediaChannel = true;
/** 信令通道 */
this.signalChannel = null;
/** 媒体配置 */
@@ -551,6 +634,7 @@ function Taoyao(
this.configWebrtc = function(config = {}) {
this.webrtc = config;
this.webSocket = this.webrtc.signal.address;
this.shareMediaChannel = this.webrtc.framework === 'MOON';
defaultRPCConfig.iceServers = this.webrtc.stun.map(v => ({'urls': v}));
console.debug('WebRTC配置', this.webrtc, defaultRPCConfig);
return this;
@@ -560,8 +644,8 @@ function Taoyao(
signalChannel.taoyao = this;
this.signalChannel = signalChannel;
// 不能直接this.push = this.signalChannel.push这样导致this对象错误
this.push = function(data, callback) {
this.signalChannel.push(data, callback)
this.push = function(data, pushCallback) {
this.signalChannel.push(data, pushCallback);
};
return this.signalChannel.connect(this.webSocket, callback);
};
@@ -607,11 +691,6 @@ function Taoyao(
})
.then(resolve)
.catch(reject);
// 兼容旧版
// navigator.getUserMedia({
// audio: self.audioConfig,
// video: self.videoConfig
// }, resolve, reject);
})
.catch(e => {
console.error('检查终端设备异常', e);
@@ -625,12 +704,17 @@ function Taoyao(
};
/** 远程终端过滤 */
this.remoteClientFilter = function(sn, autoBuild) {
if(sn === signalConfig.sn) {
console.warn('远程终端等于本地终端');
return this.localClient;
}
let array = this.remoteClient.filter(v => v.sn === sn);
let remote = null;
if(array.length > 0) {
remote = array[0];
} else if(autoBuild) {
remote = new TaoyaoClient(sn);
remote = new TaoyaoClient(this, sn, this.shareMediaChannel);
remote.openMediaChannel();
this.remoteClient.push(remote);
}
return remote;
@@ -647,8 +731,9 @@ function Taoyao(
this.buildMediaChannel = async function(localVideoId, stream) {
let self = this;
// 本地视频
this.localClient = new TaoyaoClient(signalConfig.sn, this.localClientAudioEnabled, this.localClientVideoEnabled);
this.localClient = new TaoyaoClient(this, signalConfig.sn, this.shareMediaChannel, this.localClientAudioEnabled, this.localClientVideoEnabled);
await this.localClient.buildStream(localVideoId, stream);
if(this.shareMediaChannel) {
// 本地通道
this.localMediaChannel = new RTCPeerConnection(defaultRPCConfig);
this.localClient.audioTrack.forEach(v => this.localMediaChannel.addTrack(v, this.localClient.stream));
@@ -656,20 +741,6 @@ function Taoyao(
this.localMediaChannel.ontrack = function(e) {
console.debug('Local Media Track', e);
};
this.localMediaChannel.ondatachannel = function(channel) {
channel.onopen = function() {
console.debug('Local DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('Local DataChannel Message', data);
}
channel.onclose = function() {
console.debug('Local DataChannel Close');
}
channel.onerror = function(e) {
console.debug('Local DataChannel Error', e);
}
};
this.localMediaChannel.onicecandidate = function(e) {
// TODO判断给谁
let to = self.remoteClient.map(v => v.sn)[0];
@@ -695,20 +766,6 @@ function Taoyao(
let remote = self.remoteClient[0];
remote.buildStream(remote.sn, e.streams[0], e.track);
};
this.remoteMediaChannel.ondatachannel = function(channel) {
channel.onopen = function() {
console.debug('Remote DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('Remote DataChannel Message', data);
}
channel.onclose = function() {
console.debug('Remote DataChannel Close');
}
channel.onerror = function(e) {
console.debug('Remote DataChannel Error', e);
}
};
this.remoteMediaChannel.onicecandidate = function(e) {
// TODO判断给谁
let to = self.remoteClient.map(v => v.sn)[0];
@@ -726,7 +783,8 @@ function Taoyao(
}
));
};
console.debug('打开媒体通道', this.localMediaChannel, this.remoteMediaChannel);
console.debug('打开共享媒体通道', this.localMediaChannel, this.remoteMediaChannel);
}
return this;
};
/** 校验candidate */

View File

@@ -51,38 +51,27 @@
},
mounted() {
let self = this;
this.taoyao = new Taoyao();
this.taoyao = new Taoyao('local');
this.remoteClient = this.taoyao.remoteClient;
// 打开信令通道
this.taoyao
.buildChannel(self.callback)
.then(e => console.debug('信令通道连接成功'));
// 打开媒体通道
this.taoyao.buildLocalMedia()
.then(stream => {
self.taoyao.buildMediaChannel('local', stream);
})
.catch(e => {
console.error('打开终端媒体失败', e);
// 方便相同电脑测试
self.taoyao.buildMediaChannel('local', null);
});
},
beforeDestroy() {
},
methods: {
// 信令回调:返回true表示已经处理
// 信令回调true表示已经处理false表示没有处理
callback: function(data) {
let self = this;
switch(data.header.pid) {
case signalProtocol.client.config:
// 如果需要下发配置生效需要在此打开媒体通道
return false;
}
return false;
},
// 创建会议
create: function(event) {
let sn = prompt('你的账号', signalConfig.sn);
signalConfig.sn = sn;
let self = this;
this.taoyao.meetingCreate(data => {
self.taoyao.meetingEnter(data.body.id);

View File

@@ -1,4 +1,4 @@
package com.acgist.taoyao.test.annotation;
package com.acgist.taoyao.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;

View File

@@ -1,4 +1,4 @@
package com.acgist.taoyao.test.annotation;
package com.acgist.taoyao.annotation;
import java.lang.reflect.InvocationTargetException;
import java.util.concurrent.CountDownLatch;

View File

@@ -1,4 +1,4 @@
package com.acgist.taoyao.test.annotation;
package com.acgist.taoyao.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;

View File

@@ -3,9 +3,9 @@ package com.acgist.taoyao.boot.service;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import com.acgist.taoyao.annotation.CostedTest;
import com.acgist.taoyao.annotation.TaoyaoTest;
import com.acgist.taoyao.main.TaoyaoApplication;
import com.acgist.taoyao.test.annotation.TaoyaoTest;
import com.acgist.taoyao.test.annotation.CostedTest;
import lombok.extern.slf4j.Slf4j;

View File

@@ -7,8 +7,8 @@ import java.util.concurrent.CountDownLatch;
import org.junit.jupiter.api.Test;
import com.acgist.taoyao.annotation.TaoyaoTest;
import com.acgist.taoyao.main.TaoyaoApplication;
import com.acgist.taoyao.test.annotation.TaoyaoTest;
import lombok.extern.slf4j.Slf4j;

View File

@@ -7,9 +7,9 @@ import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import com.acgist.taoyao.annotation.TaoyaoTest;
import com.acgist.taoyao.main.TaoyaoApplication;
import com.acgist.taoyao.signal.protocol.platform.ScriptProtocol;
import com.acgist.taoyao.test.annotation.TaoyaoTest;
@TaoyaoTest(classes = TaoyaoApplication.class)
class ScriptProtocolTest {

View File

@@ -5,9 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import com.acgist.taoyao.annotation.TaoyaoTest;
import com.acgist.taoyao.main.TaoyaoApplication;
import com.acgist.taoyao.signal.protocol.platform.ShutdownProtocol;
import com.acgist.taoyao.test.annotation.TaoyaoTest;
@TaoyaoTest(classes = TaoyaoApplication.class)
class ShutdownProtocolTest {

View File

@@ -395,15 +395,15 @@ Moon模式有效
### Offer信令5997
Offer
WebRTC信令`Offer`
### Answer信令5998
Answer
WebRTC信令`Answer`
### 候选信令5999
IceCandidate
WebRTC信令`IceCandidate`主要用来解决`NAT`问题
## 测试

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.acgist</groupId>
<artifactId>taoyao</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>taoyao-test</artifactId>
<packaging>jar</packaging>
<name>taoyao-test</name>
<description>测试:测试工具</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -6,9 +6,9 @@
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| HTTPS / WSS | | SCTP | SRTP / SRTCP |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ICE / SDP / SIP +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TLS | | DTLS |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-++
| HTTP / WS | STUN / TURN | | RTP / RTCP |
| TLS | | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ DTLS +-+-+-+-+-+-+-+-+-+
| HTTP / WS | NAT / STUN / TURN | | RTP / RTCP |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TCP | UDP |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+