GStreamer で WebRTC を使用する

GStreamer では、webrtcbin を使用することで WebRTC を用いて映像を配信することができます。 webrtcbin は、gst-plugins-bad で公開されているため、gst-plugins-good などに比べると品質が担保されていませんので、注意する必要があります。

WebRTC とは

WebRTC とは、「Web Real-Time Communication」の略称で、以下の図のように互いの端末間で P2P のコミニュケーションを行うための仕組みです。

WebRTC の仕組み

WebRTC で P2P の通信をおこなうためには、通信相手のIPアドレスなど接続先の情報や映像・音声のコーデック情報などが必要になります。
これらの情報をやり取りするために、シグナリングサーバを使用します。

シグナリングサーバを経由し、互いの情報を共有して、P2P での接続を行います。
互いの情報をやり取りに使用するためのプロトコルが SDP と ICE になります。

SDP

SDP(Session Description Protocol) は、P2P 通信を行うための基本情報を WebRTC 間で共有するための情報交換用のプロトコルになります。

SDP を使って共有する情報は以下の 2 つになります。
  • 端末が扱うことのできるコーデックの情報
  • 端末の IP アドレスやポート番号などの情報

SDP のサンプル

v=0
o=- 7493095794315410814 0 IN IP4 0.0.0.0
s=-
t=0 0
a=ice-options:trickle
a=group:BUNDLE video0 audio1 application2
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=setup:actpass
    :

ICE

ICE(Interactive Connectivity Establishment) は、通信可能な経路を交換するためのプロトコルになります。

ICE はお互いのネットワーク情報を確認した後、WebRTC 同士が通信するために利用できる通信経路の一覧を取得します。
ICE によって取得された接続経路の一覧は 「ICE Candidate」 と呼ばれます。

WebRTC では通信を開始する時に ICE Candidate が見つかった順に接続を試みて、最初に接続できた経路で通信を行います。

ICE Candidate のサンプル

candidate:1 1 UDP 2015363327 10.0.2.15 39183 typ host
candidate:2 1 TCP 1015021823 10.0.2.15 9 typ host tcptype active
    :

WebRTC は、シグナリングサーバで、やり取りを行なった SDP・ICE を使用して、WebRTC 同士で P2P コミニュケーションを行います。

簡易的な説明は以上になります。

他にも 「STUNサーバー」 や 「TURNサーバー」 と言った NAT 越えを行うための機能がありますが、webrtcbin を使用するのには、さほど必要になりませんので、ここでは説明を割愛します。

サンプル実装

github にソースコードがアップしてありますので、全体のプログラムはそちらで確認を行なってください。
https://github.com/nobuo-kobayashi/docker-webrtc-sample

シグナリングサーバ

SDP や ICE のプロトコルは決まっていますが、シグナリングサーバを経由してのやり取りについては決まりがありません。 サンプルでは、シグナリングサーバ経由の SDP と ICE の情報のやり取りには以下のような JSON 形式で行います。

SDP 情報を格納する JSON オブジェクト
{
	"type": "sdp",
	"data": {
		"type": "offer",
		"sdp": "v=0\r\no=- 4564429387953326769 0 IN IP4 0.0.0.0\r\n・・・省略・・・"
	}
}
ICE 情報を格納する JSON オブジェクト
{
	"type": "ice",
	"data": {
		"sdpMLineIndex": 0,
		"candidate": "candidate:1 1 UDP 2015363327 10.0.2.15 36986 typ host"
	}
}

サンプルのシグナリングサーバは、nodejs で作成します。

互いのメッセージを、そのまま渡すだけのシンプルなシグナリングサーバになっています。 1 対多の通信はできないので、複数の人に映像を配信したい場合には修正する必要があります。

'use strict'

const port = process.env.PORT || 9449
const htmlDir = '/opt/data/html'

let connections = []
let index = 0

const express = require('express')
const app = express()
const expressWs = require('express-ws')(app);

app.ws('/', function(ws, req) {  
  let connectionId = 'conn_' + (index++)
  connections[connectionId] = ws

  console.log('ws connect...');

  // 自分以外の全員にメッセージを送信
  function _send(message) {
    for (let key in connections) {
      if (key != connectionId) {
        try {
          let _ws = connections[key]
          if (_ws) {
            _ws.send(message)
          }
        } catch (e) {
          console.log('websocket.send() error.', e)
        }
      }
    }
  }

  ws.on('message', function(message) {
    _send(message)
  });

  ws.on('close', function() {
    _send("playerDisconnected")
    delete connections[connectionId]
  });

  _send("playerConnected")
});

app.use(express.static(htmlDir))
app.listen(port, () => console.log('Listening on port ' + port + '...'))

シグナリングサーバは、接続された時に、接続済みのクライアントに対して playerConnected を送信します。 切断時には、接続しているクライアントに対して playerDisconnected を送信します。 クライアントから送られてくるメッセージは、シグナリングサーバでは処理を行わずに、他のクライアントに送信します。

gst-webrtc-sample

gstreamer に webrtcbin というエレメントが存在し、WebRTC を扱うことができます。 ですが、webrtcbin は、パイプラインだけでは使用することができません。

シグナリングサーバとのやり取りなどの処理を C、C++、Python といった言語でプログラムを実行する必要があります。 サンプルでは、C++ を用いて実装を行なっています。

シグナリングサーバとは Websocket で接続を行います。 シグナリングサーバからのメッセージで、WebRTC のパイプラインの開始、停止、SDP/ICE のやり取りなどを行います。

playerConnected のメッセージが送られてきた場合には、クライアントが接続されたので、WebRTC の配信を開始します。
playerDisconnected のメッセージが送られてきた場合には、クライアントが切断されたので、WebRTC の配信を停止します。
それ以外のメッセージは、SDP/ICE になっているために、JSON を解釈して、WebRTC に設定を行います。

void WebRTCMain::connectSignallingServer(std::string& url, std::string& origin)
{
  disconnectSignallingServer();

  mClient = new WebsocketClient();
  mClient->setListener(this);
  mClient->connectAsync(url, origin);
}

void WebRTCMain::onMessage(WebsocketClient *client, std::string& message)
{
  const char *text = message.c_str();
  if (g_strcmp0(text, "playerConnected") == 0) {
    startPipeline();
  } else if (g_strcmp0(text, "playerDisconnected") == 0) {
    stopPipeline();
  } else {
    praseSdpAndIce(message);
  }
}

サンプルで使用するパイプラインは以下を使用します。
STUN サーバには、stun://stun.l.google.com:19302 を使用します。

  std::string bin = "webrtcbin name=webrtcbin bundle-policy=max-bundle latency=100 stun-server=stun://stun.l.google.com:19302 \
        videotestsrc is-live=true \
         ! videoconvert \
         ! queue \
         ! vp8enc target-bitrate=10240000 deadline=1 \
         ! rtpvp8pay \
         ! application/x-rtp,media=video,encoding-name=VP8,payload=96 \
         ! webrtcbin. \
        audiotestsrc is-live=true \
         ! audioconvert \
         ! audioresample \
         ! queue \
         ! opusenc \
         ! rtpopuspay \
         ! application/x-rtp,media=audio,encoding-name=OPUS,payload=97 \
         ! webrtcbin. ";

シグナリングサーバから playerConnected が呼ばれ、WebRTC の配信が開始された時に、以下の関数が呼び出されます。 この関数では、上記のパイプラインを作成し、パイプラインから webrtcbin 取得しコールバックを設定して、パイプラインの再生を開始します。

void WebRTCPipeline::startPipeline(std::string& bin)
{
  GError *error = NULL;

  mPipeline = gst_parse_launch(bin.c_str(), &error);
  
  mWebRTCBin = gst_bin_get_by_name(GST_BIN(mPipeline), "sendrecv");

  // 接続するためのネゴシエーションを行うためのコールバックを設定
  mNegotiationNeededHandleId = g_signal_connect(mWebRTCBin, "on-negotiation-needed", 
      G_CALLBACK(WebRTCPipeline::onNegotiationNeeded), this);
  mSendIceCandidateHandleId = g_signal_connect(mWebRTCBin, "on-ice-candidate", 
      G_CALLBACK(WebRTCPipeline::onSendIceCandidate), this);
         :
  // 再生を開始
  gst_element_set_state(GST_ELEMENT(mPipeline), GST_STATE_PLAYING);
}

パイプラインの再生が開始されると、on-negotiation-needed で登録した下記のコールバック関数が呼び出されます。 このコールバック関数が呼び出された時に offer 用の SDP を webrtcbin から取得します。
取得した SDP は、シグナリングサーバ経由でクライアントに送信します。

void WebRTCPipeline::onNegotiationNeeded(GstElement *webrtcbin, gpointer userData)
{
  WebRTCPipeline *pipeline = (WebRTCPipeline *) userData;
  GstPromise *promise = gst_promise_new_with_change_func(WebRTCPipeline::onOfferCreated, userData, NULL);
  g_signal_emit_by_name(pipeline->mWebRTCBin, "create-offer", NULL, promise);
}

void WebRTCPipeline::onOfferCreated(GstPromise *promise, gpointer userData)
{
  WebRTCPipeline *pipeline = (WebRTCPipeline *) userData;

  g_assert_cmphex(gst_promise_wait(promise), ==, GST_PROMISE_RESULT_REPLIED);

  GstWebRTCSessionDescription *offer = NULL;

  const GstStructure *reply = gst_promise_get_reply(promise);
  gst_structure_get(reply, "offer", GST_TYPE_WEBRTC_SESSION_DESCRIPTION, &offer, NULL);
  gst_promise_unref(promise);

  promise = gst_promise_new();
  g_signal_emit_by_name(pipeline->mWebRTCBin, "set-local-description", offer, promise);
  gst_promise_interrupt(promise);
  gst_promise_unref(promise);

  pipeline->sendSdp(offer);

  gst_webrtc_session_description_free(offer);
}

offer 用の SDP をクライアントに送信した後に、answer 用の SDP がクライアントから送られてきます。送られてきた SDP を webrtcbin に設定します。

void WebRTCPipeline::onAnswerReceived(const gchar *sdpString)
{
  GstSDPMessage *sdp = NULL;

  int ret = gst_sdp_message_new(&sdp);
  g_assert_cmphex(ret, ==, GST_SDP_OK);

  ret = gst_sdp_message_parse_buffer((guint8 *) sdpString, strlen(sdpString), sdp);
  if (ret != GST_SDP_OK) {
    g_error ("Could not parse SDP string\n");
    return;
  }

  onAnswerReceived(sdp);
}

void WebRTCPipeline::onAnswerReceived(GstSDPMessage *sdp)
{
  GstWebRTCSessionDescription *answer = gst_webrtc_session_description_new(GST_WEBRTC_SDP_TYPE_ANSWER, sdp);
  if (answer) {
    GstPromise *promise = gst_promise_new();
    g_signal_emit_by_name(mWebRTCBin, "set-remote-description", answer, promise);
    gst_promise_interrupt(promise);
    gst_promise_unref(promise);
    gst_webrtc_session_description_free(answer);
  }
}

また、パイプラインの再生を開始すると on-ice-candidate で登録した以下の関数に webrtcbin から ICE Candidate の情報が送られてきます。 この情報もシグナリングサーバ経由でクライアントに送信します。

このコールバック関数が呼び出されるタイミングは、 on-negotiation-needed とは別に非同期で送られてきますので、注意してください。

void WebRTCPipeline::onSendIceCandidate(GstElement *webrtcbin, guint mlineindex, gchar *candidate, gpointer userData)
{
  WebRTCPipeline *pipeline = (WebRTCPipeline *) userData;
  if (pipeline) {
    pipeline->sendIceCandidate(mlineindex, candidate);
  }
}

クライアントからも ICE Candidate の情報が送られてきますので、その情報を webrtcbin に設定します。

void WebRTCPipeline::onIceReceived(guint mlineIndex, const gchar *candidateString)
{
  g_signal_emit_by_name(mWebRTCBin, "add-ice-candidate", mlineIndex, candidateString);
}

上記の SDP と ICE のやり取りに成功することで、WebRTC の映像配信が開始されます。

クライアント

gst-webrtc-sample に接続するためのクライアントが必要になります。 Websocket の接続や Chrome ブラウザの RTCPeerConnection を使用して、gst-webrtc-sample に接続することができます。

サンプルでは、 簡単に使用できるように webrtc ライブラリを用意してあります。

以下のように stun サーバを設定して、webrtc.playStream を呼び出すことで、接続できるようになっています。 また、 gst-webrtc-sample と同じ stun サーバを設定する必要があります。

window.onload = function() { 
  let vidstream = document.getElementById("stream");
  let config = {
    'iceServers': [
      { 'urls': 'stun:stun.l.google.com:19302' }
    ]
  };
  webrtc.playStream(vidstream, location.hostname, 9449, null, config, (msg) => {
    addText(msg);
  }, (errmsg) => {
    console.error(errmsg);
  });
};

Chrome ブラウザで起動

Chrome ブラウザで、クライアントを表示すると以下のような映像が表示されます。

gst-webrtc-sample のパイプラインを変更して、v4l2src で Web カメラの映像を配信することもできます。

  std::string bin = "webrtcbin name=webrtcbin bundle-policy=max-bundle latency=100 stun-server=stun://stun.l.google.com:19302 \
        v4l2src device=/dev/video0 \
         ! image/jpeg,width=1280,height=720,framerate=30/1 \
         ! jpegdec \
         ! videoconvert \
         ! queue \
         ! vp8enc target-bitrate=10240000 deadline=1 \
         ! rtpvp8pay \
         ! application/x-rtp,media=video,encoding-name=VP8,payload=96 \
         ! webrtcbin. \
        audiotestsrc is-live=true \
         ! audioconvert \
         ! audioresample \
         ! queue \
         ! opusenc \
         ! rtpopuspay \
         ! application/x-rtp,media=audio,encoding-name=OPUS,payload=97 \
         ! webrtcbin. ";

厳密には計測は行なっていないですが、 Wi-Fi のローカルネットワーク内では、遅延が 200ms 程度になっています。

今回のサンプルでは、映像のエンコードやデコードに vp8enc や jpegdec などのソフトウェアのエンコーダ、デコーダを使用しています。
この部分を nvh264enc、omxh264enc、nvjpegdec などのハードウェアを使用したエンコーダ、デコーダを使用することで多少遅延を少なくすることができると思います。