WebRTC对等体还需要查找并交换本地和远程音频和视频媒体信息,例如分辨率和编解码器功能。 交换媒体配置信息的信令通过使用被称为SDP的会话描述协议格式来交换,被称为提议和应答的元数据块
WebRTC 音视频通信基本流程
- 一方发起调用 getUserMedia 打开本地摄像头
- 媒体协商(信令交换,媒体协商主要指 SDP 交换。)
- 建立通信
发起端 Amy 创建 Offer 并将 Offer 信息,并调用 setLocalDescription 将其保存起来,通过信令服务器传送给接收端 Bob
接收端 Bob 收到对等端 Amy 的 Offer 信息后调用 setRemoteDescription 方法将其保存起来,并创建 Answer 信息,同理也将 Answer 消息通过 setLocalDescription 保存,并通过信令服务器传送给呼叫端 Amy
呼叫端 Amy 收到对等端 Blob 的 Answer 信息后调用 setRemoteDescription 方法将其 Answer 保存起来
为什么需要媒体协商?
媒体协商的作用就是让双方找到共同支持的媒体能力,从而能实现彼此之间的音视频通信。比如两个人想聊天,一个人只会讲中文,喜欢讨论前端;一个人只会讲英文,不喜欢前端技术;通过互换资料,发现这没法聊天。但如果出现另一个人会讲中文,喜欢研究代码和前端技术,跟第一个人交换信息后,发现这可以聊天。所以发起端与接收端能不能进行通信。
媒体协商是在做什么?
媒体协商就是在交换 SDP的过程。会话发起者通过创建一个offer,经过信令服务器发送到接收方,接收方创建answer并返回给发送方,完成交换。
SDP 是什么?
SDP(Session Description Protocol)指会话描述协议,是一种通用的协议,基于文本,其本身并不属于传输协议,需要依赖其它的传输协议(如 RTP 交换媒体信息。
SDP 主要用来描述多媒体会话,用途包括会话声明、会话邀请、会话初始化等。通俗来讲,它可以表示各端的能力,记录有关于你音频编解码类型、编解码器相关的参数、传输协议等信息。
交换 SDP 时,通信的双方会将接受到的 SDP 和自己的 SDP 进行比较,取出他们之间的交集,这个交集就是协商的结果,也就是最终双方音视频通信时使用的音视频参数及传输协议。
offer 和 answer 是什么?
在双方要建立点对点通信时,发起端发送的 SDP 消息称为 Offer,接收端发送的 SDP 消息称为 Answer
所以,offer 和 answer 本质就是存有 SDP 信息的对象,所以也会叫做 SDP Offer 和 SDP Answer。
信令与信令服务器
信令通常指的是为了网络中各种设备协调运作,在设备之间传递的控制信息。
对于 WebRTC 通信来说,发起端发送 Offer SDP 和接收端接受 Answer
SDP,要怎么发给对方呢?这个过程还需要一种机制来协调通信并发送控制消息,这个过程就称为信令。
而信令对应的服务器就叫信令服务器,作为中间人帮助建立连接,主要负责:
信令的处理,如媒体协商消息的传递
管理房间信息。比如用户连接时告诉信令服务器自身的房间号,由信令服务器找到也在该房间号的对等端并开始尝试通信,也通知用户谁加入了房间和离开了房间,通知房间人数是否已满等等,所以也叫信令服务器也叫房间服务器。
WebRTC 并没有规定信令必须使用何种实现,目前业界使用较多的是 WebSocket + JSON/SDP 的方案。其中 WebSocket 用来提供信令传输通道,JSON/SDP 用来封装信令的具体内容。
ICE
当媒体协商完成后,WebRTC 就开始建立网络连接,其过程称为 ICE(Interactive Connectivity Establishment)交互式连接建立。
ICE 不是一种协议,整合了 STUN 和 TURN 两种协议(用于 NAT 穿透)的框架。
ICE 是在各端调用 setLocalDescription() 后就开始了,其操作过程如下:
- 收集 Candidate
- 交换 Candidate
- 按优先级尝试连接
什么是 Candidate?
比如想用 socket 连接某台服务器,一定要知道这台服务器的一些基本信息,如服务器的 IP 地址、端口号以及使用的传输协议。只有知道了这些信息,才能与这台服务器建立连接。而 Candidate 正是 WebRTC 用来描述它可以连接的远端的基本信息,因此 Candidate 是至少包括 IP 地址、端口号、协议的一个信息集。
const peerA = new RTCPeerConnection()
const peerB = new RTCPeerConnection()
API
pc.createOffer:创建 offer 方法,方法会返回 SDP Offer 信息
pc.setLocalDescription 设置本地 SDP 描述信息
pc.setRemoteDescription:设置远端的 SDP 描述信息,即对方发过来的 SDP 信息
pc.createAnswer:远端创建应答 Answer 方法,方法会返回 SDP Offer 信息
pc.ontrack:设置完远端 SDP 描述信息后会触发该方法,接收对方的媒体流
pc.onicecandidate:设置完本地 SDP 描述信息后会触发该方法,打开一个连接,开始运转媒体流
pc.addIceCandidate:连接添加对方的网络信息
pc.setLocalDescription:将 localDescription 设置为 offer,localDescription 即为我们需要发送给应答方的 sdp,此描述指定连接本地端的属性,包括媒体格式
pc.setRemoteDescription:改变与连接相关的描述,该描述主要是描述有些关于连接的属性,例如对端使用的解码器
RTCPeerConnection就代表着一个peer的连接,它是WebRTC应用核心。
- 1、调用peerA (RTCPeerConnection对象) createOffer方法准备创建SDP
- 2、在createOffer的回调方法里,同时做了这两件事
a、调用peerA的setLocalDescription(description)方法,这个方法会触发peerA的icecandidate
监听方法handleConnection. 在这个方法里,会将peerA的icecandidate发送给peerB.
然后PeerB执行addIceCandidate(candidate),将peerA的candidate登记在案.
b、将peerA的description (就是SDP)发送给peerB
- 3、peerB收到peerA发来的SDP,执行createAnswer,在这个回调方法里,同时做两件事
a、调用peerB的setLocalDescription(description)方法,这个方法会触发peerB的icecandidate监听方法handleConnection,在这个方法里,会将peerB的icecandidate发送给peerA.
peerA收到后执行addIceCandidate(candidate),将peerB的candidate也登记
b、将peerB的SDP发送给peerA.
- 4、peerA和peerB开始传递音视频
总结:整个过程就是peerA和peerB互相交换iceCandidate和SDP的过程。
import { useEffect, useRef, useState } from 'react'
import './App.css'
function App() {
const localVideoRef = useRef<HTMLVideoElement>(null)
const remoteVideoRef = useRef<HTMLVideoElement>(null)
const pc = useRef<RTCPeerConnection>()
const localStreamRef = useRef<MediaStream>()
const wsRef = useRef(new WebSocket('ws://127.0.0.1:1234'))
const username = (Math.random() + 1).toString(36).substring(7)
const [status, setStatus] = useState('开始通话')
useEffect(() => {
initWs()
getMediaDevices().then(() => {
createRtcConnection()
addLocalStreamToRtcConnection()
})
}, [])
const initWs = () => {
wsRef.current.onopen = () => console.log('ws 已经打开')
wsRef.current.onmessage = wsOnMessage
}
const wsOnMessage = (e: MessageEvent) => {
const wsData = JSON.parse(e.data)
console.log('wsData', wsData)
const wsUsername = wsData['username']
console.log('wsUsername', wsUsername)
if (username === wsUsername) {
console.log('跳过处理本条消息')
return
}
const wsType = wsData['type']
console.log('wsType', wsType)
if (wsType === 'offer') {
const wsOffer = wsData['data']
pc.current?.setRemoteDescription(new RTCSessionDescription(JSON.parse(wsOffer)))
setStatus('请接听通话')
}
if (wsType === 'answer') {
const wsAnswer = wsData['data']
pc.current?.setRemoteDescription(new RTCSessionDescription(JSON.parse(wsAnswer)))
setStatus('通话中')
}
if (wsType === 'candidate') {
const wsCandidate = JSON.parse(wsData['data'])
pc.current?.addIceCandidate(new RTCIceCandidate(wsCandidate))
console.log('添加候选成功', wsCandidate)
}
}
const wsSend = (type: string, data: any) => {
wsRef.current.send(JSON.stringify({
username,
type,
data,
}))
}
const getMediaDevices = async () => {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
})
console.log('stream', stream)
localVideoRef.current!.srcObject = stream
localStreamRef.current = stream
}
const createRtcConnection = () => {
const _pc = new RTCPeerConnection({
iceServers: [
{
urls: ['stun:stun.stunprotocol.org:3478'],
}
]
})
_pc.onicecandidate = e => {
if (e.candidate) {
console.log('candidate', JSON.stringify(e.candidate))
wsSend('candidate', JSON.stringify(e.candidate))
}
}
_pc.ontrack = e => {
remoteVideoRef.current!.srcObject = e.streams[0]
}
pc.current = _pc
console.log('rtc 连接创建成功', _pc)
}
const createOffer = () => {
pc.current?.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
})
.then(sdp => {
console.log('offer', JSON.stringify(sdp))
pc.current?.setLocalDescription(sdp)
wsSend('offer', JSON.stringify(sdp))
setStatus('等待对方接听')
})
}
const createAnswer = () => {
pc.current?.createAnswer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
})
.then(sdp => {
console.log('answer', JSON.stringify(sdp))
pc.current?.setLocalDescription(sdp)
wsSend('answer', JSON.stringify(sdp))
setStatus('通话中')
})
}
const addLocalStreamToRtcConnection = () => {
const localStream = localStreamRef.current!
localStream.getTracks().forEach(track => {
pc.current!.addTrack(track, localStream)
})
console.log('将本地视频流添加到 RTC 连接成功')
}
return (
<div>
<div>{`username:${username}`}</div>
<video style={{ width: '400px' }} ref={localVideoRef} autoPlay controls></video>
<video style={{ width: '400px' }} ref={remoteVideoRef} autoPlay controls></video>
<br />
<p>{`当前状态:${status}`}</p>
<br />
{status === '开始通话' && (
<button onClick={createOffer}>拨号</button>
)}
{status === '请接听通话' && (
<button onClick={createAnswer}>接听</button>
)}
</div>
)
}
export default App