SkyWay WebRTC Gatewayでクライアントの通話の様子をサーバ側でハンドルする

WebRTCスタックを簡単に開発できるSDK&APIの「SkyWay」で、自前で通話の録画の仕組みを実装する記事です。スマホ、PC等各種デバイスに対応した録画の仕組みを考えます。

## SkyWayでの録画・録音アプローチ

SkyWay(に限らずとも)での録画・録音の方法はいくつかあります。まず公式でSkyWay Media Pipeline Factoryという素晴らしいツールを提供しています。
https://m-pipe.netlify.app/m-pipe/
このツールでは、AWSやGCPなどのクラウドをつなぎ合わせて、リアルタイムに音声のキャプチャを行えます。しかしこれは音声キャプチャのみ対応していて、映像のとりこみは現時点ではできません。

次に、MediaRecorder APIがあります。
https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
こちらはブラウザ側でカメラのストリームをキャプチャできるAPIですが、iOSのChrome・Safari、AndroidのChromeに対応していなかったりするので、スマホWEBをカバーするのは厳しいと言えます。

さらに、公式から提供されているSkyWay WebRTC Gatewayというものもあります。
https://github.com/skyway/skyway-webrtc-gateway
これはいわゆるWebRTC Gatewayツール(合ってるはず..)で、WebRTCをWEB・アプリのみならず、IP通信ができる全てのデバイスでも利用できるように拡張してしまおうというノリのツールです(README参照)。
WEB・アプリ以外のデバイスから、RTPを相手に対して送信したり、逆にMediaStreamを受け取ったり、DataChannel経由でデータの送受信を行うことができます。

先述のSkyWay Media Pipeline Factoryと、MediaRecorder APIをつかうやり方では、スマホ・PCまで対応しつつ録画・録音を行うのが厳しいと言えるので、今回はSkyway WebRTC Gatewayを使ってサーバ側で録画・録音をする仕組みを作っていきます。

1:1の通話アプリで、両者の映像・音声を記録する、というのを前提に進めていきます。

※ちなみに、もっと安定的に録画するなら、例えばSFUを介して通信し、SFUサーバ内でストリームをキャプチャするのも手だと思います。

## 方法

ちょっと無理矢理な方法ですが。。
まずクライアントからSkyWay WebRTC Gatewayでストリームを受け取ります。次にローカルにRTPをフォワードしてそれをffmpegで受け取ります。最後にffmpegのオプションでローカルにファイルを書き出し続けるように設定して終了という流れです。具体的には以下のフローです。

1. 通話開始時、クライアントはSkyWay WebRTC Gatewayとシグナリングを交わしつつ、通話相手ともシグナリングします。
1. SkyWay WebRTC Gatewayでストリームを受け取ったら、ローカルアドレスにRTPをリダイレクト。
1. ffmpegでローカルアドレスのRTPを入力にしつつ、VP8/Vorbisなコーデックで.webmを吐き出すようなコマンドを起動
1. 通話が始まったらリアルタイムでストリームが録画・録音される

## 構成

具体的には下記画像のような構成です。

![skyway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73555/af82bd40-1655-a270-70e2-66e2b6fae0b5.png)


SkyWay WebRTC Gateway側では、帯域節約のため、受信のみをする設定でクライアントとコネクションを張り、通話中はずっと接続し続けます。

## 実装

キャプチャする側のコードは、公式が提供しているサンプルコードを元に実装にしています。
https://github.com/skyway/skyway-webrtc-gateway/tree/master/samples

まず、ストリームを受け取る部分のコードです。

```main.rb
require "json"
require "net/http"
require "socket"
require './util.rb'

peer_id = "TestRecorder"

# クライアントから受け取ったRTPのストリームを下記のアドレスにリダイレクトする
# リダイレクト先(IPv4)
RECV_ADDR = "127.0.0.1"
# リダイレクト先ポート
VIDEO_RTP_RECV_PORT = 5000
AUDIO_RTP_RECV_PORT = 5002

peer_token = create_peer("SKYWAY API キー", peer_id)

# 相手からコールがかかってくるのをまつ
media_connection_id = wait_call(peer_token, peer_id)

video_redirect = [RECV_ADDR, VIDEO_RTP_RECV_PORT]
audio_redirect = [RECV_ADDR, AUDIO_RTP_RECV_PORT]

answer_res = answer(media_connection_id, video_redirect, audio_redirect)
wait_ready(media_connection_id)

# 相手からストリームを受け取るのを待つ
thread_event = wait_thread_for("/media/connections/#{media_connection_id}/events", event: "STREAM", ended: lambda { |e|
  puts "received stream"
  puts e
})

# 通話が終わるまで待つ
sleep(300)
```

次にmain.rbから参照している諸々のメソッドを書きます。

```lib.rb
def create_peer(key, peer_id)
  params = {
    "key": key,
    "domain": "localhost",
    "turn": true,
    "peer_id": peer_id,
  }
  res = request(:post, "/peers", JSON.generate(params))
  if res.is_a?(Net::HTTPCreated)
    json = JSON.parse(res.body)
    json["params"]["token"]
  else
    p res
    exit(1)
  end
end

def wait_call(peer_token, peer_id)
  media_connection_id = nil
  thread_event = wait_thread_for("/peers/#{peer_id}/events?token=#{peer_token}", event: "CALL", ended: lambda{ |e|
    media_connection_id = e["call_params"]["media_connection_id"]
  })
  thread_event.join
  media_connection_id
end

def answer(media_connection_id, video_redirect, audio_redirect)
  params = {
    # こちら側からメディアは配信しないのでvideo/audioは無効に
    "constraints": {
      "video": false,
      "audio": false,
      "videoReceiveEnabled": true,
      "audioReceiveEnabled": true
    },
    "redirect_params": {
      "video": {
        "ip_v4": video_redirect[0],
        "port": video_redirect[1],
      },
      "audio": {
        "ip_v4": audio_redirect[0],
        "port": audio_redirect[1],
      }
    },
  }
  res = request(:post, "/media/connections/#{media_connection_id}/answer", JSON.generate(params))
  json = parse_response(res)
end

def wait_ready(media_connection_id)
  thread_event = wait_thread_for("/media/connections/#{media_connection_id}/events", event: "READY", ended: lambda { |e|
    puts e
  })
  thread_event.join
end

def wait_thread_for(uri, event: "OPEN", ended: nil)
  e = nil
  thread_event = Thread.new do
    while e == nil or e["event"] != event
      r = request(:get, uri)
      e = parse_response(r)
    end
    if ended
      ended.call(e)
    end
  end.run
  thread_event
end

def request(method_name, uri, *args)
  response = nil
  Net::HTTP.start("localhost", "8000") { |http|
    response = http.send(method_name, uri, *args)
  }
  response
end

def parse_response(json)
  if json.is_a?(Net::HTTPCreated)
    JSON.parse(json.body)
  elsif json.is_a?(Net::HTTPOK)
    JSON.parse(json.body)
  elsif json.is_a?(Net::HTTPAccepted)
    JSON.parse(json.body)
  elsif json.is_a?(String)
    JSON.parse(json)
  else
    json
  end
end
```

以上のコードでは、ストリームを受け取って、ローカルにリダイレクトしています。
ちなみに、映像と音声をそれぞれRTPで5000番と5002番にリダイレクトしていますが、慣習的にRTPの一個上のポートは、RTCP向けのポートとして解放することが多いです。SkyWayもそれにならって、RTPの1個上のポートは、RTCP向けのポートとして確保されているようです。RTCPもリダイレクトする場合は

- 5000 -> video RTP
- 5001 -> video RTCP
- 5002 -> audio RTP
- 5003 -> audio RTCP

のようなポートの使い方をすると良いのではないかと思います。
次は、クライアント側の処理を軽く書いてみます。

## クライアント側でPeer生成

```javascript
// ...
// SkyWayのJavaScript SDKをロード
// ...

const peer = new Peer({
  key: "SKYWAY API KEY",
  turn: true
});
navigator.mediaDevices.getUserMEdia({
  video: true,
  audio: true,
  // まだ試験的な機能ですがマイクエコーがかかりづらくなるらしい
  echoCancellationType: "system",
}).then(cameraStream => {
  // SkyWay WebRTC Gatewayをコール
  peer.call("TestRecorder", cameraStream, {
    videoCodec: "VP8",
  });

  // ...必要に応じて通話相手をコール...

});
```

SkyWay WebRTC Gatewayをコールする簡易的な処理ができたので
次に、ffmpegでRTPを待ち構えてエンコードする部分を書きます。

まず、オーディオは以下のコマンドで録音。

## 音声の録音

最初は下記のコマンドを試してみました。VP8で配信したストリームを受け取って、webmで保存する設定です。webmはVorbisかOpus音声コーデックをサポートしているので、とりあえずVorbisにエンコードしています。

```shell
ffmpeg -i rtp://127.0.0.1:5001 -acodec libvorbis output_audio.webm
```

または、下記のようにコーデックをコピーする設定でもいけると思います。

```shell
# ダウンロードしてブラウザにDnDしてすぐ聴きたいので
# webmに音声だけ取り込む形にしてみた
ffmpeg -i rtp://127.0.0.1:5001 -acodec copy output_audio.webm
```

ちなみに下記コマンドでも録音できました。

```shell
# 音声コーデックを指定しない
ffmpeg -i rtp://127.0.0.1:5001 -vocodec copy output_audio.webm
```

次に映像のキャプチャのコマンドです。

## 映像の録画

ffmpegでライブのRTPを映像入力として扱う場合は、RTPのアドレスを指定するのではなくSDPファイルでの指定じゃないと食えませんでした。
なので、まず下記のようなSDPファイルを用意しました。
127.0.0.1:5000で、VP8な映像ストリームを待ち構える設定です。

```input.sdp
SDP:
v=0
o=- 0 0 IN IP4 127.0.0.1
s=No Name
c=IN IP4 127.0.0.1
t=0 0
a=tool:libavformat 58.29.100
m=video 5000 RTP/AVP 96
b=AS:1000
a=rtpmap:96 VP8/90000
```

下記にSDPの簡単なフォーマット仕様を書いておきます。

vはプロトコルバージョン。必ず0が入ります。
https://tools.ietf.org/html/rfc4566#section-5.1

oはオリジン情報。
https://tools.ietf.org/html/rfc4566#section-5.2
下記のような形式で記述します。usernameやsess-id(セッションID)が必要ない場合は、 `-` を記述すればOKです。

```
o=<username> <sess-id> <sess-version> <nettype> <addrtype>
        <unicast-address>
```

sはセッション名。指定必須です。
https://tools.ietf.org/html/rfc4566#section-5.3

cはコネクションデータ。指定必須です。
https://tools.ietf.org/html/rfc4566#section-5.7
下記のような形式で記述します。

```
c=<nettype> <addrtype> <connection-address>
```

bは帯域情報です。指定は任意でOKです。
https://tools.ietf.org/html/rfc4566#section-5.8

aは任意の属性を下記の形式で指定できます。

```
a=<attribute>
a=<attribute>:<value>
```
https://tools.ietf.org/html/rfc4566#section-5.13

mは、受け取るメディアの情報を指定します。
https://tools.ietf.org/html/rfc4566#section-5.14
下記の形式で指定できます。

```
m=<media> <port> <proto> <fmt> ...
```

以上、SDPのフォーマットの簡単な説明でした。
ちなみに上記SDPファイルは、アドレスやビットレート情報は適宜書き換えてください。
最後に、ffmpegでの録画コマンドは下記のものを使いました。
映像のみ受け取るので、videoだけコーデックをコピーする設定。

```shell
ffmpeg -analyzeduration 30M -probesize 30M \
    -protocol_whitelist file,crypto,udp,rtp \
    -i input.sdp \
    -vcodec copy \
    output_video.webm
```

本当に最低限の部分ですが、これで自動で録画・録音ができるシステムが完成しました。

音声・映像両方キャプチャし終えたら、２つのファイルを１つにmixすれば普通の動画が完成すると思います。

## 最後に

今回実装したコードはこちらです。
https://github.com/OdaDaisuke/skyway-stream-recorder

ちなみに、JanusなどでもRTPのフォワードをして同じようなことができるのではないかと思います。

今回はffmpegでファイルを書き出していますが、例えばAWS Media LiveにさらにRTPを転送すればHLSやDASHでブロードキャストするのも原理的には可能です。どこかで試してみたいですね。
※MediaLiveはRTPでVP8が食えなかったので、H.264にトランスコードして送信する必要がありそう。

## 参考にしたページのリンク

https://github.com/skyway/skyway-webrtc-gateway

https://tools.ietf.org/html/rfc4566

https://stackoverflow.com/questions/42900514/stream-rtp-to-ffmpeg-using-sdp

https://stackoverflow.com/questions/39411901/how-can-i-merge-two-input-rtp-streams-in-ffmpeg