kurento-group-callのサンプルにMCUを追加する


## 概要

Kurentoのインストールとサンプルアプリの起動を行った[前回](https://qiita.com/Otama75/items/7153607d3cde868aaf5c)からの続きです。

KurentoのTutorialには、kurento-group-callという多人数でビデオ会議を行うサンプルアプリケーションがあります。しかし、このサンプルの接続モデルはSFUで、MCUを実現したものがありません。そこで、オリジナルのkurento-group-callにMCUによる接続を追加し、合成後の映像ストリームもユーザに送るようにしたサンプルを作成してみます。

映像の合成は、Kurentoで提供されるCompositeMixerエレメントを使います。

今回作成したソースコードは[こちら](https://github.com/kozokomiya/kurento-group-call-mcu)です。



## パイプラインの構造

オリジナルのSFU版kurento-group-call (下の図左側)に、CompositeMixer(Compositeエレメント)を追加して各ユーザからの映像を一つに合成したあとで、WebRTCでユーザに送り返します。この図は、３人のユーザが同じルームに参加したときのパイプライン構造を示しています。

![groupcall_structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189951/dfd59d13-7eb2-6bc1-5205-d279f02674a4.png)


### Compositeエレメント

Compositeエレメントは、標準のKurento elementsライブラリに含まれる映像と音声の合成を行うメディアエレメントです。WebRtcEndpointなどのメディアソースとなるエレメントを複数接続することで、一つのメディアストリームに合成することができます。

メディアの合成を行うには、Compositeに入力用メディアのHubPortを作成して、そのHubPortにWebRtcEndpointなどのメディアソースとなるエレメントを接続します。複数の画像合成を行う場合それぞれのHubPortを作成してエレメントを接続する必要があります。

合成後の映像を取得する場合にもHubPortの追加が必要です。追加の方法は入力用の場合と全く同じですが、HubPortを送信先のエレメントに接続すると、そのHubPortが出力先ポートになります。

Compositeは複数の映像をタイル状にレイアウトしますが、映像の合成レイアウトを変更することはできません。変更するには、Compositeそのものソースコードを変更して作り変えてやる必要があります。

ビデオ会議のルームからユーザが退出した場合は、対応するユーザのWebRtcEndpointをリリースして、対応するHubPortもリリースする必要があります。ただし、HubPortの追加・削除を何度か行うと合成映像が固まってしまうという[バグ](https://groups.google.com/g/kurento/c/EQQFpBiJwvw/m/ZFiyrm2jBAAJ)があるようです。

## ソースコード上の変更点のポイント

今回のサンプルアプリケーションは、kurento-tutorial-javaに含まれるkurento-group-callを改変して作成します。kurento-group-callをサブディレクトリごとコピーして、ソースコードを変更します。すべてのソースコードは、srcフォルダの中にあります。この内、main/java の中にあるのが、kurento-clientのJAVAコードresouces/staticにあるのが、ブラウザ上で動作するhtmlとJavascriptのコードです。

```
src
├── assembly
└── main
    ├── java/org/kurento/tutorial/groupcall
    │                   ├── CallHandler.java
    │                   ├── GroupCallApp.java
    │                   ├── Room.java
    │                   ├── RoomManager.java
    │                   ├── UserRegistry.java
    │                   └── UserSession.java
    └── resources
        ├── application.properties
        ├── banner.txt
        ├── keystore.jks
        └── static
            ├── index.html
            ├── js
            │   ├── conferenceroom.js
            │   └── participant.js
            └── style.css
```

今回は、ブラウザ上で動作するコードは、一切変更しません。変更したのはkurento-client側のJAVAコードのみです。それぞれの変更点の概要を説明します。

### GroupCallApp.java

GroupCallのメインクラスです。修正点はありません。

### RoomManager.java

複数のRoomを管理するクラスです。

あまり修正点はありません。最後の一人がRoomから抜けた後、もう一度roomが作成されてしまうバグを修正しています。

### CallHandler.java

ブラウザとのメッセージのやり取りを行うクラスです。

あまり修正点はありません。"recieveVideoFrom"メッセージを受けた後、UserSessionクラスではなく、Sender名の文字列をrecieveVideoFromメソッドの引数に設定するようにしました。

最後の一人がRoomから抜けた後、もう一度roomが作成されてしまうバグに対応しています。

### Room.java

Roomを実装するクラスです。

コンストラクタで、RoomのパイプラインにCompositeエレメントとCompositeの出力用HubPortを追加しています。

```Room.java
public class Room implements Closeable {
  private final Logger log = LoggerFactory.getLogger(Room.class);

  private final ConcurrentMap<String, UserSession> participants = new ConcurrentHashMap<>();
  private final MediaPipeline pipeline;
  private final Composite mixer;
  private final HubPort mixerOut;
  private final String name;

  public String getName() {
    return name;
  }

  public Room(String roomName, MediaPipeline pipeline) {
    this.name = roomName;
    this.pipeline = pipeline;
    this.mixer = new Composite.Builder(pipeline).build();
    this.mixerOut = new HubPort.Builder(this.mixer).build();
    log.info("ROOM {} has been created", roomName);
  }

```

ユーザがJoinした後、現在Room内にいるメンバーをexistingParticipantsメッセージを使って、ブラウザに通知しますが、ここでCompositeの出力ストリームも受け取ってもらうためメンバーリストにRoom名を追加して送信するようにしています。

```Room.java
  public void sendParticipantNames(UserSession user) throws IOException {

   ...

    // add room name for to recieve mixer's output
    final JsonElement mixerName = new JsonPrimitive(this.name);
    participantsArray.add(mixerName);
    
  ...
  }
```

### UserRegistry.java

複数ユーザを管理するクラスです。修正点はありません。

### UserSession.java

Userに紐づく処理を実装するクラスです。

```UserSession.java
public class UserSession implements Closeable {
  private static final Logger log = LoggerFactory.getLogger(UserSession.class);

  private final String name;
  private final WebSocketSession session;

  private final Room room;
  private final WebRtcEndpoint outgoingMedia;
  private final WebRtcEndpoint mixerMedia;
  private final HubPort mixerHub;
  private final ConcurrentMap<String, WebRtcEndpoint> incomingMedia = new ConcurrentHashMap<>();

  public UserSession(final String name, Room room, final WebSocketSession session) {

    this.room = room;
    this.name = name;
    this.session = session;
    this.outgoingMedia = new WebRtcEndpoint.Builder(this.room.getPipeline()).build();
    this.mixerMedia = new WebRtcEndpoint.Builder(this.room.getPipeline()).build();
    this.mixerHub = new HubPort.Builder(this.room.getMixer()).build();
    this.room.getMixerOut().connect(mixerMedia, MediaType.VIDEO);
    this.outgoingMedia.connect(mixerHub);
    this.outgoingMedia.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {

      @Override
      public void onEvent(IceCandidateFoundEvent event) {
        JsonObject response = new JsonObject();
        response.addProperty("id", "iceCandidate");
        response.addProperty("name", name);
        response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
        try {
          synchronized (session) {
            session.sendMessage(new TextMessage(response.toString()));
          }
        } catch (IOException e) {
          log.debug(e.getMessage());
        }
      }
    });
    this.mixerMedia.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {

      @Override
      public void onEvent(IceCandidateFoundEvent event) {
        JsonObject response = new JsonObject();
        response.addProperty("id", "iceCandidate");
        response.addProperty("name", UserSession.this.getRoomName());
        response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
        try {
          synchronized (session) {
            session.sendMessage(new TextMessage(response.toString()));
          }
        } catch (IOException e) {
          log.debug(e.getMessage());
        }
      }
    });

  }

```

メンバー変数でにRoom roomを追加し、Roomに付随するプロパティはroomのgetter関数からもらってくるようにしました（コード全体で変更）。この変更によりコンストラクタの引数をシンプルにしています。これにより、roomNameとpipelineはメンバから削除しました。

また、メンバー変数にCompositeへの入力となるHubPort mixerHubと、Compositeからの出力をブラウザに送信するWebRtcEndpoint mixerMediaを追加してそれぞれ接続("connect")しています。mixerMeidaについて、WebRTCのシグナリングを行うハンドラを追加しています。ここで、Compositeの出力であるmixerOutからmixerMediaの接続のときに、`mediaType.VIDEO`を指定する事により、音声なしの映像のみを取得しています。Compositeでは自分の音声も一緒に合成してしまうため、音声なしにしないと自分の声がスピーカから同時に鳴ってしまいハウリングを起こしてしまう可能性があるためです。

receiveVideoFromメソッドで、引数にRoom名が指定された場合、mixerMediaに接続できるようにgetEndpointForUserメソッドと一緒に変更しています。

```UserSession.java
  public void receiveVideoFrom(String senderName, String sdpOffer) throws IOException {
    log.info("USER {}: connecting with {} in room {}", this.name, senderName, this.getRoomName());

    log.trace("USER {}: SdpOffer for {} is {}", this.name, senderName, sdpOffer);

    final String ipSdpAnswer = this.getEndpointForUser(senderName).processOffer(sdpOffer);
    final JsonObject scParams = new JsonObject();
    scParams.addProperty("id", "receiveVideoAnswer");
    scParams.addProperty("name", senderName);
    scParams.addProperty("sdpAnswer", ipSdpAnswer);

    log.trace("USER {}: SdpAnswer for {} is {}", this.name, senderName, ipSdpAnswer);
    this.sendMessage(scParams);
    log.debug("gather candidates");
    this.getEndpointForUser(senderName).gatherCandidates();
  }

  private WebRtcEndpoint getEndpointForUser(final String senderName) {
    if (senderName.equals(name)) {
      log.debug("PARTICIPANT {}: configuring loopback", this.name);
      return outgoingMedia;
    } else if (senderName.equals(this.getRoomName())) {
      log.debug("PARTICIPANT {}: receiving video from mixer", this.name);
      return mixerMedia;
    }
  ...
 }
```

あと、close() メソッドで、コンストラクタで追加したWebRtcEndpointとHubPortのリリース処理を追加しています。

WebRtcのシグナリング処理を行う、addCandidiate()メソッドでroom名が指定されたときにmixerMediaのハンドラで処理が行えるように追加をしています。



## 使い方

まずは、前回の方法でkurento-media-serverをセットアップして、kurento-tutorial-javaにあるkurento-group-callが動作することを確認しましょう。

動作したら、環境のセットアップは完了しているはずなので、ソースコードを取得します。

```console
git checkout https://github.com/kozokomiya/kurento-group-call-mcu.git
cd kurento-group-call-mcu
```

./start.shのスクリプトの変数`HOST_ADR`にkurento-media-serverが動作するのホストのホスト名またはIPアドレスを入力します。

```start.sh
# HOST name or IP Address of kurento-media-server HOST
HOST_ADR=xx.xx.xx.xx
```

サンプルアプリを起動します。

```console
./start.sh
```

ブラウザでアプリのURLを指定して開きます。`https://localhost:8443/` or `https://<sample-application-host>:8443` . 

Room名をUser名を指定して"Join"します。このときUser名とRoom名は同じにしないでください。ブラウザ側では合成後の映像を「Room名」というメンバーがいると思って取得するためです。

別のブラウザ（別タブでOK）を開いて、同じRoom名を指定して"Join"します。２人目が入室時点で、ブラウザ上には、「自分のカメラのループバック画像」、「２番目のユーザの画像(SFU接続による)」と、「Compositeにより合成された画像」の３つが表示されます。

３人入室すると以下のような感じになります。同じ画像が２ついるのは、同じPCの別タブで開いているからです。

![スクリーンショット 2021-02-10 17.30.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189951/53d3ab41-3b35-6c13-7445-2b6b20bb50b7.png)

### 既知の問題

userのJoinとLeaveを何度か繰り返すと、Composite出力の映像が固まってしまうことがあります。これはKurentoオリジナルのCompositeそのもののバグらしいです。

## まとめ

Kurentoのサンプルのチュートリアルを変更して、MCU接続のGroup Callを行うサンプルを作成してみました。