一、WebRTC

浏览器API

  • MediaStream通过MediaStream的API能够通过设备的摄像头及话筒获得视频、音频的同步流。
  • RTCPeerConnectionRTCPeerConnection是WebRTC用于构建点对点之间稳定、高效的流传输的组件。
  • RTCDataChannelRTCDataChannel使得浏览器之间(点对点)建立一个高吞吐量、低延时的信道,用于传输任意数据。
WebRTC-internal-structure-cn.jpg
image.png

先用ajax和ws传递信息,建立信道
再用RTC点对点传输

视频压缩技术:H264/H265、VP8/VP9、AV1
webRTC:集成回音消除,视频编解码,跨平台
支持WebRTC的浏览器:Chrome、Firefox、Safari、Edge。支持情况具体:https://cloud.tencent.com/document/product/647/16863

image.png

终端:音视频采集,编解码,NAT穿越,音视频传输
signal服务器:信令处理
STUN /TURN:获取终端在公网的IP,以及NAT穿越失败后数据中转

track:视频轨,音频轨
stream:媒体流存放0音频视频轨,数据流存放0数据轨

API

var promise = navigator.mediaDevices.getUserMedia(constraints);
const mediaStreamContrains = {
    video: true,
    audio: true
};

const mediaStreamContrains = {
    video: {
        frameRate: {min: 20}, 
        width: {min: 640, ideal: 1280},
        height: {min: 360, ideal: 720},
      aspectRatio: 16/9 // 宽高比
    },
    audio: {
        echoCancellation: true, // 回音消除
        noiseSuppression: true, // 降噪
        autoGainControl: true // 自动增益
    }
};
image.png

音频原理-采样率:人的听觉是20-20kHZ,大于最高频率两倍就算是无损所以平时采样率都是44.1k\48k等;
采样大小:8位(255),16位

每个设备信息:id,label,kind
编码帧:压缩后的帧
H264:I帧(关键帧),P帧(参考帧),B帧(前后参考帧)

RTP协议

image.png

拆包组合
序号,时间戳,PT(音频和视频不一样)


image.png

RTP可能发生丢包、乱序、抖动,使用RTCP
RTCP:RR和SR收和发交换报文,来看网络质量


image.png

header:标识报文类型
sender info:发送多少包
report block: 接收包的情况

SDP(Session Description Protocal)

在webRTC中


image.png

image.png

会话元数据
网络描述
流描述
安全描述
服务质量描述

媒体协商

RTCPeerConnection


image.png
var pcConfig = null;
var pc = new RTCPeerConnection(pcConfig); // 创建peer对象
// 呼叫方创建offer
   function doCall() {
       console.log('Sending offer to peer');
       pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
  }  
// 发送给被呼叫方
  function setLocalAndSendMessage(sessionDescription) {
      pc.setLocalDescription(sessionDescription);
      sendMessage(sessionDescription);
  }
// 呼叫方收到anser
  socket.on('message', function(message) {
      ...
      } else if (message.type === 'answer') {
    
          pc.setRemoteDescription(new RTCSessionDescription(message));
      } else if (...) {
          ...
      }
      ....
  });

ICE Candidate
本地IP/端口、候选者类型(host/srflx/relay)、优先级、传输协议、访问服务的用户名

{
  IP: xxx.xxx.xxx.xxx,
  port: number,
  type: host/srflx/relay, //本机,内网主机映射的外网地址和端口,中继候选者
  priority: number,
  protocol: UDP/TCP,
  usernameFragment: string
  ...
}

先对host类型进行联通测试,按照优先级试

ICE

本机收集所有host类型
STUN拿到公网IP,收集srflx
再用TURN收集relay

NAT

WeChatWorkScreenshot_9dc62a12-9267-4c9a-aaad-9dc9ce7dcac3.png

流程梳理

选择类型

  • 登入rtc manager:返回streamID和信令服务器地址(可返回ws地址)
    demo参数
config  设置相关配置
localVideo 本地流播放标签null
remoteVideo  远程流播放标签
nickname streamID
recevieDataCallback  receiveMessageData方法
  • 登入信令服务器,get方法,返回peers(可以用new ws建立连接)


    WXWorkCapture_15935094097369.png
  • 构造demo
    构造函数里面:构造PeerConnSignaling,构造call

异步等待两个事件 newMessage,newNotification

ice过程

开发经验

改造谷歌原生代码

代码地址:https://github.com/webrtc/apprtc
先跑起来然后魔改源码,简化代码,抽取信令,改造信令。源码解读有空再开别的文章。

信令设计

msg_type字段定义

sign_in_event 前端登入
accept_in_event 后端登入
recv_src_id 接收源id
recv_dst_id 接收目标id
offer_sdp_event 转发sdp
answer_sdp_event 转发sdp
ice_event 转发ice
bye 发送断开请求
error 信令服务器发送错误信息
ping  向信令服务器发ping
pong  信令服务器返回pong
reconn_event 重新连接
update_session_event 切换事件

交互过程中前端状态机

1、建立连接

var ws = new WebSocket(url);

2、登入,message里面放原来的字段,结构不变,加一个msgType字段表示发送消息类型

signMsg = {
    msg_type:'sign_in_event',
    message:{
        protocol:  "webRTC",
        media: {
            audio: {
                ...
            },
            video: {
                ...
            }
        },
        svrType: 0
    }
}
ws.send(JSON.stringify(signMsg))

3、登入后返回自身id

{
    msg_type: 'recv_src_id',
    message:{
      stream_id:'1594014948578356499vdptx',
      src_id:1
    }
}

4、等待返回对端id

// 成功
{
    msg_type: 'recv_dst_id',
    message:{
      stream_id:'1594014948578356499vdptx',
      dst_id:2
    }
}

5、发送前端sdp,message里面和原sdp字段相同

sdpMsg = {
    msg_type:'offer_sdp_event',
    src_id:1,
    dst_id:2,
    message:{
        type:'offer',
        sdp:''
    }
}
ws.send(JSON.stringify(sdpMsg))

6、发送anser sdp

{
    msg_type: 'answer_sdp_event',
    src_id: 2,
    dst_id: 1,
    message:{
      type:'answer',
      sdp:'...'
    }
}

5、发送前端candidate,message里面和原candidate字段相同

iceMsg = {
    msg_type:'ice_event',
    src_id:1,
    dst_id:2,
    message:{
      type:'candidate',
      candidate:'...',
      id:'...',
      label:'...'
    }
}
ws.send(JSON.stringify(iceMsg))

8、等待接收对端candidate

{
    msg_type: "ice_event",
    src_id: 2,
    dst_id: 1,
    message:{
      type:'candidate',
      candidate:'...',
      id:0,
      label:'...'
    }
}

9、结束连接,先发送一个断开的消息,再断开ws

byeMsg = {
    msg_type:'bye',
    src_id:1,
    dst_id:2,
}
ws.send(JSON.stringify(byeMsg))

10、错误消息

{
    msg_type: "error",
    src_id:2/null,
    dst_id:1,
    message:{
      // 失败原因
    }
}

11、ping

pingMsg = {
    msg_type:'ping',
    src_id:1,
    dst_id:2,
}
ws.send(JSON.stringify(pingMsg))

12、回复ping

{
    msg_type:'pong',
    src_id:2,
    dst_id:1/null,
}

13、重连

reconnMsg = {
    msg_type:'reconn_event',
    message: {
      stream_id,
      src_id:1,
      dst_id:2,
    }
}
ws.send(JSON.stringify(reconnMsg))

14、更新

updateMsg = {
    msg_type:'update_session_event',
    src_id:1,
    dst_id:2,
    message: {
        protocol:  "webRTC",
        media: {
            audio: {
                ...
            },
            video: {
                ...
            }
        },
        svrType: 0
    }
}
ws.send(JSON.stringify(updateMsg))

信令单独走websocket,关于WS的限制,浏览器无法ping pong

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+

具体每一bit的意思
FIN      1bit 表示信息的最后一帧
RSV 1-3  1bit each 以后备用的 默认都为 0
Opcode   4bit 帧类型,操作码
Mask     1bit 掩码,是否加密数据,默认必须置为1
Payload  7bit 数据的长度
Masking-key      1 or 4 bit 掩码
Payload data     (x + y) bytes 数据
Extension data   x bytes  扩展数据
Application data y bytes  程序数据

OPCODE字段:4位
解释PayloadData,如果接收到未知的opcode,接收端必须关闭连接。
0x0表示附加数据帧
0x1表示文本数据帧
0x2表示二进制数据帧
0x3-7暂时无定义,为以后的非控制帧保留
0x8表示连接关闭
0x9表示ping
0xA表示pong
0xB-F暂时无定义,为以后的控制帧保留

验证浏览器自动回复pong

ws = require('ws');
wss = new ws.Server({port: 7777});
wss.on('connection', conn_ws => {  console.log(conn_ws);
conn_ws.on('pong', msg => console.log('get cli pong', msg))
conn_ws.ping("clq");})

简单的get请求

const express = require('express')
const app = express()
const port = 3000
app.get('/v1/get123/', (req, res) => res.send('Hello World!'))
app.listen(port, () => console.log(`Example app listening on port ${port}!`))

错误码

http://blog.sina.com.cn/s/blog_145f07e7b0102x3x1.html

原生API再写一版最简版本

基础上看了一门课

image.png

代码地址: https://github.com/avdance/webrtc_web
找到官方文档API,跟着api写了一版SDK,可以自由嵌入任何前端框架,大致思路,此项目现在已废弃脱敏。

import { sendBye, offLine, sendPing, onLine, notifyIce, sendCallResponse, sendCall, sendCancelCall } from './wsSignal'

function webrtcPlayer(option) {
    /**
     * 私有变量,无需加this,模块变量外部无法获取
     * 参数:内部websocket连接,本地流,远程流,远端连接,重新计数次数,websocket是否打开标识
     */
    let _ws
    let _localStream
    let _remoteStream
    let _remotePeerConnection
    let _reconnCount = 0
    let _wsOpen = true
    let _iceCandidateCache = []

    /**
     * 传入参数
     * readme有详细解释
     */
    let handleMessage = option.handleMessage || null
    let wsHost = option.wsHost
    let stunHost = option.stunHost || ""
    let pingTime = option.pingTime || 5
    let reconnTimes = option.reconnTimes || 3
    //set constraints
    const mediaStreamConstraints = option.mediaStreamConstraints || {
        video: true,
        audio: true,
    }
    // Set up to exchange only video.
    const offerOptions = option.offerOptions || {
        offerToReceiveVideo: 1,
        offerToReceiveAudio: 1,
    }
    // Define peer connections, streams and video elements.
    let localVideo = option.localVideo || null
    let remoteVideo = option.remoteVideo || null
    let localId = option.localId || ''
    let remoteIds = option.remoteId || []
    let remoteId = ''

    let sessionId = Date.parse(new Date()) + 's'

    // Sets the MediaStream as the video element src.
    const gotLocalMediaStream = (mediaStream) => {
        return new Promise(function (resolve) {
            // @ts-ignore
            if (localVideo) {
                localVideo.srcObject = mediaStream;
            }
            _localStream = mediaStream;
            resolve();
        })
    }

    function callAction() {
        // Add local stream to connection and create offer to connect.
        _remotePeerConnection.addStream(_localStream);
        _remotePeerConnection.createOffer(offerOptions).then(createdOffer).catch();
    }

    // Handles remote MediaStream success by adding it as the remoteVideo src.
    function gotRemoteMediaStream(event) {
        const mediaStream = event.stream;
        remoteVideo.srcObject = mediaStream;
        _remoteStream = mediaStream;
    }


    function reconnect() {
        if (_reconnCount >= reconnTimes - 1) {
            _reconnCount = 0
            wsClose()
        } else {
            _reconnCount++
            wsOnline()
        }
    }

    let sdp = ''
    function receiveCall() {
        console.log('receiveCall');
        const servers = null; // Allows for RTC server configuration.
        _remotePeerConnection = new RTCPeerConnection(servers)
        navigator.mediaDevices
            .getUserMedia(mediaStreamConstraints)
            .then(gotLocalMediaStream)
            .then(() => {
                _remotePeerConnection.addEventListener('icecandidate', handleConnection)
                _remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream)
                // 获取对端sdp,返回callResponse
                let description = new RTCSessionDescription({
                    type: 'offer',
                    sdp: sdp,
                });
                _remotePeerConnection.addStream(_localStream);
                _remotePeerConnection.setRemoteDescription(description)
                _remotePeerConnection.createAnswer().then(createdAnswer)
            })
    }

    function cancelCall() {
        _ws.send(sendCancelCall(sessionId, localId, remoteId))
    }

    const wsClose = value => {
        if (remoteVideo && remoteVideo.srcObject) {
            remoteVideo.srcObject.getTracks().forEach(track => track.stop())
        }
        if (localVideo && localVideo.srcObject) {
            localVideo.srcObject.getTracks().forEach(track => track.stop())
        }
        if (_remotePeerConnection) {
            _remotePeerConnection.close()
            _remotePeerConnection = null
        }
        if (value) {
            _ws.send(sendBye(sessionId, localId, remoteId))
        }
    }

    const wsOffline = () => {
        // wsClose(true)
        _wsOpen = false
        _ws.send(offLine(localId))
        _ws.close()
    }

    const wsOnline = (local_id, remote_id, token, extra_data) => {
        localId = local_id
        remoteIds = remote_id
        console.log('connect')
        _ws = new WebSocket(wsHost)
        _ws.onopen = () => {
            console.info('注册连接成功')
            _wsOpen = true
            // setInterval(() => {
            //     _ws.send(sendPing(localId));
            // }, pingTime * 1000);
            _ws.send(onLine(localId, token, pingTime + 2, extra_data))
        };
        _ws.onclose = () => {
            if (_wsOpen) {
                reconnect()
            } else {
                handleMessage({evt: 'onClose'})
                console.log('连接已关闭')
            }
        };
        _ws.onmessage = (evt) => {
            let message = JSON.parse(evt.data);
            console.log('---evt.data---', message)
            if (message.msg_id === 'onlineResponse') {
                // 获取对端sdp,收集ice
                console.log('登录成功');
            }
            if (message.msg_id === 'callResponse') {
                handleMessage({evt: 'onCallResponse'})
                // 获取对端sdp,收集ice
                let description = new RTCSessionDescription({
                    type: 'answer',
                    sdp: message.data.sdp,
                });
                _remotePeerConnection.setRemoteDescription(description);
                remoteId = message.data.callee_ids[0]
                _iceCandidateCache.forEach(iceCandidate => {
                    _ws.send(notifyIce(sessionId, localId, remoteId, iceCandidate))
                })
            }
            if (message.msg_id === 'call') {
                sdp = message.data.sdp
                sessionId = message.data.session_id
                remoteId = message.data.caller_id
                handleMessage({evt: 'onCall', remoteId: remoteId})
            }
            if (message.msg_id === 'cancelCall') {
                sessionId = message.data.session_id
                handleMessage({evt: 'onCancelCall'})
            }
            if (message.msg_id === 'notifyIceCandidate') {
                let iceCandidate = new RTCIceCandidate({
                    candidate: message.data.candidate,
                    sdpMid: message.data.sdp_mid,
                    sdpMLineIndex: message.data.sdp_mline_index,
                });
                if (iceCandidate && _remotePeerConnection && _remotePeerConnection.remoteDescription) {
                    console.log(iceCandidate)
                    console.log(_remotePeerConnection)
                    _remotePeerConnection.addIceCandidate(iceCandidate).catch(e => {
                        console.log("Failure during addIceCandidate(): " + e.name);
                    });
                }
            }
            if (message.msg_id === 'bye') {
                handleMessage({evt: 'onBye'})
                wsClose()
            }
        };
    };

    const wsCall = (remote_id) => {
        console.log('call');
        if (remote_id) {
            remoteIds = remote_id
        }
        _iceCandidateCache = []
        const servers = null; // Allows for RTC server configuration.
        _remotePeerConnection = new RTCPeerConnection(servers)
        navigator.mediaDevices
            .getUserMedia(mediaStreamConstraints)
            .then(gotLocalMediaStream)
            .then(() => {
                _remotePeerConnection.addEventListener('icecandidate', handleConnectionCall)
                _remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream)
                callAction()
            })
    };

    function handleConnection(event) {
        const peerConnection = event.target;
        const iceCandidate = event.candidate;
        if (iceCandidate && iceCandidate.candidate) {
            _ws.send(notifyIce(sessionId, localId, remoteId, iceCandidate));
        }
    }

    function handleConnectionCall(event) {
        const peerConnection = event.target;
        const iceCandidate = event.candidate;
        if (iceCandidate && iceCandidate.candidate) {
            _iceCandidateCache.push(iceCandidate)
        }
    }

    // Logs offer creation and sets peer connection session descriptions.
    function createdOffer(description) {
        _ws.send(sendCall(sessionId, localId, remoteIds, description.sdp));
        _remotePeerConnection.setLocalDescription(description);
    }

    // Logs answer to offer creation and sets peer connection session descriptions.
    function createdAnswer(description) {
        _ws.send(sendCallResponse(sessionId, localId, remoteId, description.sdp))
        _remotePeerConnection.setLocalDescription(description);
    }

    return {
        wsCall: wsCall,
        wsClose: wsClose,
        receiveCall: receiveCall,
        cancelCall: cancelCall,
        wsOnline: wsOnline,
        wsOffline: wsOffline,
    }
}

export default webrtcPlayer;

其它方面

1、用kurento写一版
2、端上只有小程序无法实现,需要用腾讯云TRTC,需要云上翻译才能对接,无法直接接webRTC
3、关于保活,很复杂也很重要
4、建立音视频,时序很重要,缓存做不好不行

项目已经不做一年了,记录下当时的一些过程

对于新技术

1、源码很重要,看懂入口出口,各种函数流转过程
2、官方API很重要,浏览器是一个黑盒子,封装了很多神奇的API,直接用。当然第三方库的思路差不多,区别就是可以看源码,白盒子。
3、快速入门能花钱的就要花,有前人总结过最好,当然是随缘,最近也做到很多无人涉及的板块。
4、所以英语好很重要,无中文资料的项目太多了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容