WebRTC 入门

2,074 阅读16分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

WebRtc 技术简介

WebRTC(Web 实时通信)是一种使 Web 应用程序之间传递流媒体的技术。他使得用户在浏览器之间交换任意数据的而无需任何中间件。他有如下特点

  1. p2p 的网络拓扑:

webRtc 相较于传统的流媒体服务器:

  • 降低了服务器成本和负载
  • 带宽利用率更高
  • 减少了数据在传输过程中的暴露风险,提高了通信的安全性。

但是在较大规模的会议场景下,由于客户端压力过大(p2p 连接数会随着用户数量增加而指数增加),一般还是会基于流媒体服务器实现。

  1. 跨平台和多样的媒体设备兼容

webrtc 通过自身强大的音视频引擎,对各种媒体设备和常见的平台做了广泛的兼容,使得用户基本不用关心媒体流及其加密、传输等功能的具体实现,开箱即用

  1. 强大的自适应网络能力

webRtc 对各种网络环境进行处理,提供了 p2p 连接中最为棘手的 NAT 穿透能力和高效的网络传输能力

WebRtc 相对来说,还是很底层的技术。他封装了 网络传输 和 媒体设备处理的能力,但是将其他业务场景实现的能力都交给了用户。如我们的 aiccSDK,实际上就是基于 WebRtc 之上继续封装的一整套语音通话的业务逻辑。

image.png

网络基础知识

什么是局域网?

局域网是一个局部范围的计算计组,比如家庭网络就是一个小型的局域网,里面包含电脑、手机和平板等,共同连接到一个路由器上。当机房无法上外网时,局域网中的设备之间仍可以通信。

如何在两个局域网用户间建立连接?

设想这样一个问题:在北京和上海各有一台局域网的机器(例如一台是家里的台式机,一台是连接到星巴克 WiFi 的笔记本),二者都是私网 IP 地址,如何让这两台机器通信呢?

最简单的方式当然是在公网上架设一个服务器: 两台机器分别连接到该服务,后者完成双向转发。

什么是NAT?

如果每个接入互联网的机器都需要有一个公网ip 地址,那么全球 IPv4 地址早已不够用,因此人们发明了 NAT(网络地址转换)来缓解这个问题,也就是端口号(这个端口号与服务的端口号有一些区别,服务器的端口号用于区分不同进程,NAT 设备的端口号用于区分本地子网内的不同设备的 IP 地址)。

基于 NAT,大部分机器都使用局域网中的私有 IP 地址,如果它们需要访问公网服务,那么,

  • 出向流量:需要经过一台 NAT 设备(最常见的就是路由器),它会对流量进行转换,转换成 NAT 设备的公网 IP+Port,然后再将包发出去;

  • 应答流量:到达 NAT 设备后进行上述相反的转换,然后再转发给客户端。

几种常见的NAT类

  • 完全锥形NAT:任何来自外部网络的数据包只要目标地址和端口号与映射匹配,就会被转发给内部网络中的相应设备。
  • IP 限制锥形NAT:只有那些出向流量的 IP 地址传来的的数据包才会被转发给内部网络中的相应设备

  • 端口限制锥形NAT:只有那些出向流量的 IP:port 地址传来的的数据包才会被转发给内部网络中的相应设备
  • 对称形NAT:每次内部网络中的一个设备发起对外部网络的连接请求时,NAT设备都会为这个内部设备创建一个新的外部IP地址和端口的映射。

如何实现 NAT 穿透

如何在两台经过 NAT 的机器之间建立点对点连接?在上面的 NAT 原理中,我们知道了如何讲局域网设备接入公网。

此时产生了一个问题:虽然客户端通过 NAT 设备接入并能与公网设备进行连接,但是公网设备无法主动通过这个IP 和端口号找到客户端,只能被动的等客户端访问,才能通过该 IP:port 找到该设备,原因如下:

  • 当局域网内的客户端主动发起连接到公网设备时,NAT路由器会记录这个连接的信息,包括客户端的私有IP和端口,以及发送的公网IP和端口,如(123.123.123.123:123)。
  • 这个连接的映射会保留在NAT设备的连接表中,允许该公网地址返回的数据包顺利转发到局域网内的客户端。这并不是一个单纯的转发,因此公网设备无法主动的跟客户端建立连接。

这就是 NAT 设备的常见的「有状态/有朝向防火墙」,即允许所有出向连接,禁止所有入向连接。入向包只有在有相对应的出向包的情况下才会被允许进入(UDP协议)。

TURN 方案

防火墙朝向相同的穿透:

如上图所示,从内网访问公网上的某个服务器都属于这种情况。不论是客户端使用 vpn 与否,防火墙都是朝向相同的,因此只需客户端主动发起请求,所有相应的入向包就能进来。该穿透场景是客户端与公网服务器建立链接。通过一个 TURN 服务做中转的方案:

  1. 有公网上的一个这样 TURN 服务器并且双方分别在其中注册了某个标识(类似手机号)
  2. 某一方发起通信时,就可以直接通过请求 TURN 服务器,并由其将信息转发给对方实现。

image.png

STUN 方案

防火墙朝向不同的穿透:

但如果两个“客户端”想直连,这就意味着他们的防火墙是朝向相反的,除非让用户重新配置一边的防火墙,打开一个洞,不然就无法进行穿透,这个条件相当苛刻。

但是还有别的方案:要穿透这些有状态防火墙,我们只需要:让两端提前知道对方使用的 ip:port,这可以通过一个 STUN 服务实现:

  1. 客户端 => STUN 服务:我的 ip:port 是什么?

  2. 在业务逻辑获取对方的 IP:PORT 之后(信令服务器),只需要向该地址发送一个包(无论是否能被接受,目的就已经达到了),这样就将 防火墙 开了一个洞,后续该地址向客户端传的包都会被认为是应答包而被接收。

ICE 协议

这个框架的算法是寻找连接两个对等节点的最低延迟路径,结合了上述提到的方案,通常采用以下候选者的顺序寻找(不关注 TCP 连接的情况):

  • 主机候选者:本地局域网内的 IP 地址及端口,优先级最高

  • 反射候选者:获取 NAT 内主机的外网IP地址和端口,其实就是去尝试 STUN 尽可能多的进行穿透,并通过某种算法取优

  • 中继候选者:通过服务器中转媒体数据

NAT 无法穿透的情况

如上述的对称形NAT,会根据每个不同的终点,为局域网内设备申请不同的端口,这也就意味着 STUN 服务器拿到的 ip:port 对其他 ip:port 的设备是无效的。(当然还有很多其他情况会导致无法穿透)

基于 websocket 的音视频通信 demo

webRtc 流程

本地 debugger : chrome://webrtc-internals/#185-19-table-T01

实现逻辑

服务端: 为每一个连接维护一个 websocket 实例,当一方客户端发起 offer/ice-candidate 事件时,向另一个 socket 实例发送数据,推送给另一个客户端。只需要做一些简单的转发逻辑,如:

 // 交换 ice 候选者
 socket.on('ice-candidate', (data) => {
    console.log('candidate: ', data);
    const { to, from, candidate } = data;
    socket.to(to).emit('ice-candidate', { candidate, from, to });
  });

客户端:实现通信,大致需要三步: 1. 本地设备校验,初始化 RTCPeerConnection 对象

navigator.mediaDevices.enumerateDevices()
    .then(devices => {
      devices.forEach(device => {
        console.log('本地设备:', device);
      });

      // 提示用户给予使用媒体输入权限,产生一个 MediaStream 实例(https://developer.mozilla.org/zh-CN/docs/Web/API/MediaStream)
      return navigator.mediaDevices.getUserMedia({
        audio: false,
        video: true,
      });
    })
    .then((stream) => {
      localVideo.srcObject = stream;
      localStream = stream;
      targetSockId = toSomeone.value;
      createRtc();

      callButton.disabled = false;
    })

// 创建连接
function createRtc() {
  // 创建连接
  localPeerConnection = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
    ]
  });
 }

2. 通话的发起者创建 Offer,通过 信令服务 向对方发送;接收者创建 answer ,通过信令服务发送给发起者。这个连接信息的交换过程,可能会让人联想到 TCP 的三次握手,为什么在这里建立连接只需要两次“握手”呢?

image.png

出现这个疑问,其实是对 webRtc 在应用中的位置有所不清。参考上面的画板,webRtc 技术主要是在浏览器的上一层,解决网络和跨平台的媒体设备IO问题,而交换 SDP 的过程,是在信令层(或者可以说是应用的业务逻辑)中实现的。交换 sdp 的过程是通过 ws 连接,而交换媒体流的过程是 webrtc 封装实现的,因此 这个交换过程本身并不像 tcp 握手一样需要保证连接可靠,而是单纯的信息交换。

简单理解:我们的信令逻辑,更类似于 http 的请求响应式逻辑。webRtc 的媒体交换,才更类似于 tcp 的建连过程。

function call() {
  callButton.disabled = true;
  hangupButton.disabled = false;

  // 本地创建 offer(构建SDP),发起请求
  localPeerConnection.createOffer(gotOffer, console.error);
}

function gotOffer(description) {
  // 。。。
  // 设置本地SDP - 触发 icecandidate 事件
  localPeerConnection.setLocalDescription(description);

  // 通过信令服务器发送 offer
  socket.emit('offer', { offer: description, to: targetSockId, from: mySockId });
}
  1. 通过设置本地描述,拿到本地的 ICE 候选者信息,通过 信令服务 发送给对方,用以建立对等连接
function gotLocalIceCandidate(event) {
  if (event.candidate) {
    // 获取本地的 ICE 候选者,此时需要传递给对端
    console.log('本地发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
    socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
  }
}

SDP 格式

SDP 遵循 <type>=<value> 这样的格式,由于也是通过文本传输的,可以简单理解成类似 http 头的信息。正常情况一般不需要关注

# 1. 会话级别的描述(及其字段)
v=  (protocol version)
o=  (originator and session identifier)
s=  (session name)
i=  (session information)
u=  (URI of description)
e=  (email address)
p=  (phone number)
c=  (connection information -- not required if included in all media)
b=  (zero or more bandwidth information lines)
# 2. 一个或多个时间描述
z=  (time zone adjustments)
k=  (encryption key)
a=  (zero or more session attribute lines)
# 3. 零个或多个媒体级别的描述

# 时间描述的字段有这些
t=  (time the session is active)
r=  (zero or more repeat times)

# 媒体级别的描述字段有这些
m=  (media name and transport address)
i=  (media title)
c=  (connection information -- optional if included at session level)
b=  (zero or more bandwidth information lines)
k=  (encryption key)
a=  (zero or more media attribute lines) // 拓展字段

ICE 格式及优先级

{
    "candidate": "candidate:1162430284 1 udp 2122260223 192.168.2.182 62071 typ host generation 0 ufrag nxXX network-id 1 network-cost 10",
    "sdpMid": "0",
    "sdpMLineIndex": 0,
    "usernameFragment": "nxXX"
}

其中,candidate 的值由以下信息组成:

candidate:2183725366 1 udp 2122260223 192.168.2.182 64109 typ host generation 0 ufrag +EPG network-id 1 network-cost 10

candidate: 表示这是一个 ICE 候选者报文。
2183725366: 候选者的唯一标识符(基础值)。
1: 候选者的组件 ID,通常 1 表示 RTP2 表示 RTCPudp: 候选者的传输协议,这里是 UDP2122260223: 候选者的优先级,(值越大越优先)。
192.168.2.182: 候选者的 IP 地址。
64109: 候选者的端口号。
typ host: 候选者的类型,host 表示这是本地主机的候选者。
generation 0: 候选者的生成次数,通常从 0 开始。
ufrag +EPG: 用户片段(User Fragment),用于身份验证。
network- id  1: 网络 ID,表示候选者所属的网络接口。
network-cost 10: 网络成本,表示候选者的网络质量。

常见的一些候选者的类型:

  • host 本地(局域网)UDP 连接,优先级 4

  • tcp host:优先级 2

  • srflx(Server Reflexive):通过 STUN 服务器得到的候选者,优先级 3

    • 在 srflx 字段后,会跟着 NAT 穿透的 IP 和 Port 值,如:srflx raddr 192.168.2.182 rport 64109
  • relay:通过TURN服务器转发数据,优先级 1

    • relay raddr 124.160.65.50 rport 64109

我们有很多种方式选择我们更倾向使用的连接方式,如通过信令选择性发送ICE 候选者,或者修改 SDP 。

ICE 候选者更新与SDP 交换的顺序问题?

一般情况下,RTCPeerConnection 在创建offer或answer之前或之后,双方都可以开始交换 ICE 候选者信息(这个操作与交换 offer/answer 是并行的)。当通话的发起者收到 answer 之后开始 ICE 建连流程。(在交换候选者时 debug ,可以看到这种机制:在通话双方交换offer后,且在交换候选者并建连前,是无法实现通话的。)

也就是说,在交换 ICE 候选者环节实现之前,那怕通信双方已经交换了offer/answer ,p2p 链接也并没有建立。

这种机制,通过 PeerConnection 的 canTrickleIceCandidates 只读属性可以判断。他表示当前连接的对端设备是否支持涓流ICE(Trickle ICE),该属性在 setRemoteDescription 后可读。(本地是否支持,通过 sdp 可以看出,如a=ice-options:trickle表示支持)

该协议允许逐步的发送/接收候选人,而不是完整交换整个列表。通过这种增量配置,ICE 代理就可以开始在仍在收集候选人的同时进行连接检查,显着缩短 ICE 处理所需的时间。

此外,如果一个设备不支持 Trickle ICE (即canTrickleIceCandidates 为 false),则需要在 ICE 交换流程结束后(iceGatheringState 属性为 "completed" 时),再交换 offer/answer,webRtc 会在 SDP 中包含完整的候选者列表以便一次性发送。伪代码如下:

// 接收到 offer 后处理 
pc.setRemoteDescription(remoteOffer)
    .then(_ => pc.createAnswer())
    .then(answer => pc.setLocalDescription(answer))
    .then(_ => {
        if (pc.canTrickleIceCandidates) {
            return pc.localDescription;
        }
        return new Promise(r => {
          pc.addEventListener('icegatheringstatechange', e => {
              if (e.target.iceGatheringState === 'complete') {
                  r(pc.localDescription);
              }
          });
        });
    })
    .then(answer => sendAnswer(answer))       

一些重要的 ICE 事件

交换 ICE 候选者的过程中,通常会包含多条 ICE candidates 信息,此时 WebRTC 会分别和这些 candidates 建立连接,然后选出其中最优的那条连接作为配对结果进行通话。此外,SDP 中可以通过 calculateNewPriority 函数手动配置 ICE candidates 的优先级

事件:icecandidate 事件最终会出现一个带有null候选者的参数,表示没有更多的候选者可收集。

事件:iceConnectionState,描述候选者连接状态。包含:new, checking, connected, completed, failed, disconnected, closed

connected 状态表示任意一个候选者已经成功建立了连接。即使后续发现了更合适的候选者并更换了连接,这个状态也不会发生改变。延迟调用 addIceCandidate ,不会影响他的状态,但是会更新连接

事件:iceGatheringState,描述候选者(本地)收集状态,包含枚举: 'new' 'gathering' 'complete'。

表示的是该 pc 连接的整体收集状态;而 RTCIceTransport.gatheringState 表示单个候选连接的状态。

值得注意的是,在 pc 的 iceGatheringState 变为 complete 时,如果通过添加 ice 服务的方式增加候选者(代码),他会继续变为 gathering (收集状态)。

延迟调用 addIceCandidate ,也不会影响他的状态,因为他表示的是本地端的 ICE 候选者,addIceCandidate 添加的是远程的候选者。

参考文章:

浏览器外呼通信基础

全面分析优化 AI 转人工效果

arthurchiao.art/blog/how-na…

developer.mozilla.org/zh-CN/docs/…

完整 demo 代码

// index.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');

// 创建 Express 应用
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
// 提供静态文件服务(例如 HTML 文件)
app.use(express.static('public'));

// 监听 HTTP 请求并发送主页面
app.get('/', (req, res) => {
  res.sendFile(__dirname + '/public/index.html');
});
app.get('/public', (req, res) => {
  res.sendFile(__dirname + '/public');
});

io.on('connection', (socket) => {
  socket.on('offer', (data) => {
    const { to, from, offer } = data;
    socket.to(to).emit('offer', { offer, from, to });
  });

  socket.on('answer', (data) => {
    console.log('answer: ', data);
    const { to, from, answer } = data;
    socket.to(to).emit('answer', { answer, from, to });
  });

  socket.on('ice-candidate', (data) => {
    console.log('candidate: ', data);
    const { to, from, candidate } = data;
    socket.to(to).emit('ice-candidate', { candidate, from, to });
  });

  socket.on('disconnect', (data) => {
    socket.to(data.to).emit('disconnected', data);
  });
});

server.listen(1010, () => {
  console.log('Server is running on port 1010');
});
// public/index.js
let localStream, remoteStream, localPeerConnection;

const localVideo = document.getElementById("localVideo");
const remoteVideo = document.getElementById("remoteVideo");

const startButton = document.getElementById("startButton");
const callButton = document.getElementById("callButton");
const hangupButton = document.getElementById("hangupButton");
const toSomeone = document.getElementById("to");
const Me = document.getElementById("from");
const addIceBtn = document.getElementById("addIceBtn");
const reCreateOfferBtn = document.getElementById("reCreateOfferBtn");

startButton.disabled = false;
callButton.disabled = true;
hangupButton.disabled = true;

startButton.onclick = start;
callButton.onclick = call;
hangupButton.onclick = () => hangup(true);
addIceBtn.onclick = () => updateIceServers([{ urls: 'stun:stun.l.google.com:19302' }]);
reCreateOfferBtn.onclick = reCreateOffer;

const socket = io();
let socketServerAlive = false;
let mySockId = null;
let targetSockId = null;

socket.on("connect", () => {
  socketServerAlive = true;
  Me.innerText = socket.id;
  mySockId = socket.id;
});

socket.on("disconnect", () => {
  socketServerAlive = false;
  Me.value = undefined;
  mySockId = null;
});

socket.on('ice-candidate', (data) => {
  const { to, from, candidate } = data;
  console.log('远端传来候选者: ', candidate);

  if (mySockId === to && candidate !== null) {
    // debugger
    localPeerConnection.addIceCandidate(new RTCIceCandidate(candidate));
  }
});

socket.on('offer', (data) => {
  // console.log('远端传来offer: ', data);
  const { to, from, offer: description } = data;
  if (mySockId === to) {
    // 获取到远程媒体流后,就可以在本地进行操作
    localPeerConnection.onaddstream = gotRemoteStream;
    localPeerConnection && localPeerConnection.setRemoteDescription(description);
    localPeerConnection.createAnswer(gotAnswer, console.error);
    socket.emit('answer', { answer: localPeerConnection.localDescription, to, from });
    callButton.disabled = true;
    hangupButton.disabled = false;
  }
});

socket.on('answer', (data) => {
  // console.log('远端传来answer: ', data);
  const { to, from, answer: description } = data;

  if (mySockId === to) {
    // 获取到远程媒体流后,就可以在本地进行操作
    localPeerConnection.onaddstream = gotRemoteStream;
    // setRemoteDescription() 更改远程描述时,会抛出 addstream 事件
    localPeerConnection && localPeerConnection.setRemoteDescription(description);
  }
});

socket.on('disconnect', (data) => {
  // console.log('远端传来断开连接: ');
  const { to, from } = data;
  if (mySockId === to) {
    hangup();
  }
});

function start() {
  if (!socketServerAlive) {
    return console.error('信令服务器未连接');
  }
  if (!toSomeone.value) {
    return console.log('请选择要打电话的目标');
  }

  startButton.disabled = true;

  // enumerateDevices 请求一个可用的媒体输入和输出设备的列表
  navigator.mediaDevices.enumerateDevices()
    .then(devices => {
      devices.forEach(device => {
        // console.log('本地设备:', device);
      });

      // 提示用户给予使用媒体输入权限,产生一个 MediaStream 实例(https://developer.mozilla.org/zh-CN/docs/Web/API/MediaStream)
      return navigator.mediaDevices.getUserMedia({
        audio: false,
        video: true,
      });
    })
    .then((stream) => {
      localVideo.srcObject = stream;
      localStream = stream;
      targetSockId = toSomeone.value;
      createRtc();

      callButton.disabled = false;
    })
    .catch(error => {
      console.error('媒体设备访问失败', error);
    });
}

function createRtc() {
  // 创建连接
  localPeerConnection = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
    ]
  });

  // 添加本地媒体流,用于向对端传输
  localPeerConnection.addStream(localStream);
  localPeerConnection.addEventListener('icegatheringstatechange', e => {
    console.log('ice gathering 状态改变: ', e.target.iceGatheringState);
  });
  localPeerConnection.addEventListener('connectionstatechange', e => {
    console.log('connection 状态改变: ', e.target.connectionState);
  });
}

function call() {
  callButton.disabled = true;
  hangupButton.disabled = false;

  if (localStream.getVideoTracks().length > 0) {
    // console.log('视频设备: ' + localStream.getVideoTracks()[0].label);
  }
  if (localStream.getAudioTracks().length > 0) {
    // console.log('音频设备: ' + localStream.getAudioTracks()[0].label);
  }

  // 本地创建 offer(构建SDP),发起请求
  localPeerConnection.createOffer(gotOffer, console.error);
}

function gotLocalIceCandidate(event) {
    // if(!event.candidate || event.candidate.candidate.includes('host')) return;

    // 比较奇怪的是,延迟发送更优质的 ice 候选者,并不会触发 iceGatheringState。
    // if(!event.candidate || event.candidate.candidate.includes('host')) {
    //   setTimeout(() => {
    //     console.log('延迟 7s 后发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
    //     socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
    //   }, 7000);
    // } else {
        // 获取本地的 ICE 候选者,此时需要传递给对端
        console.log('本地发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
        socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
    // }
}

function gotRemoteStream(event) {
  // 渲染对端媒体流
  remoteVideo.srcObject = event.stream;
}

function gotOffer(description) {
  // 需要将描述信息传给远端 RTCPeerConnection,以更新远端的备选源
  localPeerConnection.onicecandidate = gotLocalIceCandidate;
  // 设置本地SDP - 触发 icecandidate 事件
  localPeerConnection.setLocalDescription(description);

  // 通过信令服务器发送 offer
    console.log('发送 offer,本地SDP:', description);
    socket.emit('offer', { offer: description, to: targetSockId, from: mySockId });
}

function gotAnswer(description) {
  // 需要将描述信息传给远端 RTCPeerConnection,以更新远端的备选源
  localPeerConnection.onicecandidate = gotLocalIceCandidate;
  // 设置本地SDP - 触发 icecandidate 事件
  localPeerConnection.setLocalDescription(description);

  // 通过信令服务器发送 answer
    console.log('发送 answer,本地SDP:', description);
    socket.emit('answer', { answer: description, to: targetSockId, from: mySockId });
}

function hangup(emitDisconnect) {
  localPeerConnection.close();
  if (emitDisconnect) socket.emit('disconnect', { to: targetSockId, from: mySockId })

  localPeerConnection = null;

  hangupButton.disabled = true;
  callButton.disabled = false;
}

// 动态更新 ICE 服务器配置
function updateIceServers(newIceServers) {
  console.log('更新 ICE 服务器配置: ', newIceServers)
  localPeerConnection.setConfiguration({ iceServers: newIceServers });
}

// 重新创建 offer 
function reCreateOffer(newIceServers) {
    localPeerConnection.createOffer().then(offer => {
      return localPeerConnection.setLocalDescription(offer);
    }).then(() => {
      console.log('重新创建 offer');
    })
}
// public/index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
    "http://www.w3.org/TR/html4/loose.dtd">

<html>
  <head>
    <title>PeerConnection() example</title>
    <script src="https://cdn.socket.io/4.7.5/socket.io.min.js" integrity="sha384-2huaZvOR9iDzHqslqwpR87isEmrfxqyWOF7hr7BY6KG0+hVKLoEXMPUJw3ynWuhO" crossorigin="anonymous"></script>
  </head>
  <body>
    我是:<span id="from"></span>,
    给:<input id="to">打电话
    <table border="1" width="100%">
      <tr>
        <th>Local video</th>
        <th>'Remote' video</th>
      </tr>
      <tr>
        <td><video id="localVideo" autoplay></video></td>
        <td><video id="remoteVideo" autoplay></video></td>
      </tr>
      <tr>
        <td align="center">
          <div>
            <button id="startButton">Start</button>
            <button id="callButton">Call</button>
            <button id="hangupButton">Hang Up</button>
            <button id="addIceBtn">addIceServer</button>
            <button id="reCreateOfferBtn">reCreateOfferBtn</button>
          </div>
        </td>
        <td><!-- void --></td>
      </tr>
    </table>
    <script src="./index.js"></script>
  </body>
</html>