WebRTC(SkyWay)でステレオ配信テストやってみた

[2021/4/13：追記]　ChromeがM91でようやくステレオに対応するようです。
参考) https://bugs.chromium.org/p/webrtc/issues/detail?id=8133

# はじめに
[WebRTC Platform SkyWay](https://webrtc.ecl.ntt.com/)のプリセールスエンジニア、サポートエンジニアのBBこと馬場です。
たまにお客様からステレオ配信がうまく行かない（モノラルになる）というお問い合わせをいただきます。
WebRTC(SkyWay)でステレオ配信やるときには、いくつか注意点があるので、以前行った試験結果をまとめたものを公開します。

# なんでモノラルになっちゃうの？
この原因は二つありました。

## 原因1: Chromeのバグ(?), 仕様(?)問題
Chromeでは `gUM` の `Constraints` で `echoCancellation=true` がデフォルトになっているんですが、これがステレオに対応していないらしいです。
- [Issue 8133: OPUS stereo audio over RTP is muxed to mono](https://bugs.chromium.org/p/webrtc/issues/detail?id=8133&sort=-modified)

`echoCancellation=false` にして、opusのfmtpパラメータに `stereo=1` を追加するといいよとのこと。

## 原因2: SkyWayのSFUの仕様問題
原因1 についてはFirefoxで受ければステレオで再生できるのですが、SkyWayのSFUはステレオに対応していません。SFUでモノラルにMIXされます。そのため、ブラウザに依らずステレオ音源をそのまま配信することができません。

# ステレオ配信するために試してみたことと結果
## 観点
- `echoCancellation=false` は有効か
- Firefoxを使えば問題ないのか
- sfuを使うとどうなるのか
- LR音源を複数トラックに分けて、受信側でステレオMIXするやり方は有効か

| 観点 | 配信元 | 配信先 | room | その他 | 結果 |
|:-:|:-:|:-:|:-:|:-:|:-:|
| ベース  | ChromeM86 | ChromeM86 | mesh | 何もせずそのまま | ×モノラル |
| ベース  | ChromeM86 | ChromeM86 | sfu | 何もせずそのまま | ×モノラル |
| `echoCancellation=false` は有効か  | ChromeM86 | ChromeM86 | mesh | `echoCancellation=false` | わからず |
| Firefoxを使えば問題ないのか  | ChromeM86 | Firefox68 | mesh | 何もせずそのまま | ○ステレオ |
| sfuを使うとどうなるのか  | ChromeM86 | Firefox68 | SFU | 何もせずそのまま | ×モノラル |
| LR音源を複数トラックに分けて配信  | ChromeM86 | ChromeM86 | mesh | LRの音源を複数のMediaStreamTrackに分けて送信して、受信側でステレオMIX | ○ステレオ |

# 検証内容詳細
自宅にステレオマイクがなかったので、ステレオ音源を `<audio>` で再生してAudioContextのAPIを使って `MediaStream` として出力して使いました。
なお、WebRTCの部分はSkyWayの[JavaScript SDK](https://webrtc.ecl.ntt.com/api-reference/javascript.html)を使って実装しています。

``` JavaScript:Code
const localAudioFile = document.getElementById('js-local-audiofile');
const localStream = await getStereoStream(); 
function getStereoStream(){
  const audioCtx = new(window.AudioContext || window.webkitAudioContext);
  const source = audioCtx.createMediaElementSource(localAudioFile);
  const destination = audioCtx.createMediaStreamDestination();
  source.connect(destination);
  localAudioFile.play();
  return destination.stream;
}
```
<audio> にセットされた音源を、`AudioContext.createMediaElementSource()` に入力して、`AudioContext.createMediaStreamDestination()` で作成した出力ノードに接続して、`MediaStream` として取り出すという感じ。
※ ユーザ操作によるイベント配下におかないと再生されないので注意。
これをそのまま `peer.joinRoom()` のStreamに渡してやればOKです。

音源は http://www.gatelink.co.jp/hw/etc/audiotest/index.html からお借りしました。

## `echoCancellation=false` は有効か
[getUserMediaでステレオ入力がモノラルに変換されてしまう現象の回避@yakan10](https://qiita.com/yakan10/items/f688af6fb183c1d7a7b8) によると、`echoCancellation: false` とすればステレオで再生できるで！と書いていたので、試してみたかったのですが。。。ステレオマイク内からできへんやん！

一応、`MediaStreamTrack.applyConstraints(constraints)` で `echoCancellation: false` を適用してやってみましたが、やはり、`OverconstrainedError` がでて反映できずでした。
ステレオマイクないとだめですね。

```console:Error
OverconstrainedError {name: "OverconstrainedError", message: "Cannot satisfy constraints", constraint: ""}
constraint: ""
message: "Cannot satisfy constraints"
name: "OverconstrainedError"
__proto__: OverconstrainedError
```

```JavaScript:Code
const constraints = {
  echoCancellation: false
};

await localStream.getAudioTracks().forEach((track) => {
  track.applyConstraints(constraints)
  .catch(console.error);
});
```

### 結論: `echoCancellation=false` が有効かはステレオマイクがないため検証できませんでした！！！

## Firefoxを使えば問題ないのか
この問題は、ChromeとSafariでは確認されているもののFirefoxでは大丈夫とのことだったので、配信先をFirefoxで検証してみました。

- 配信元: ChromeM86
- 配信先: Firefox68
- room: Mesh
- そのほかはサンプルのまま 

問題なく、ステレオで再生されました。

### 結論: 配信先がFirefoxであればステレオ再生可能

## SkyWayのSFUを使うとどうなるのか
先ほどステレオ配信がうまくいった 配信先: Firefox でRoomのモードをSFUにして検証。

- 配信元: ChromeM86
- 配信先: Firefox68
- room: SFU
- そのほかはサンプルのまま 

想定どおりモノラルMIXされて再生されました。

### 結論: SFUを使うとモノラル再生になる。

## LR音源を複数トラックに分けて、受信側でステレオMIXするやり方は有効か
最後に、ステレオ音源そのまま投げてだめなら、マルチストリームでLRの音源を別で送って、受信側でステレオMIXすればいいんじゃない？ということで検証。
配信元にてステレオ音源を `audioCtx.createChannelSplitter` 使って、それぞれ別のMediaStreamTrackに分けてMediaStreamに乗せて送信し、配信先で `audioCtx.createChannelMerger` で2chにマージして再生してみました。

```JavaScript:Code(配信側)
    const localStream = new MediaStream();
    const localStreamTracks = await getStereoStreamTrack();
    function getStereoStreamTrack(){
      const audioCtx = new(window.AudioContext || window.webkitAudioContext);
      const source = audioCtx.createMediaElementSource(localAudioFile);
      const destinationL = audioCtx.createMediaStreamDestination();
      const destinationR = audioCtx.createMediaStreamDestination();
      const splitter = audioCtx.createChannelSplitter(2);
      source.connect(splitter);
      splitter.connect(destinationL, 0);
      splitter.connect(destinationR, 1);
      localAudioFile.play();
      return [destinationL.stream, destinationR.stream];
    }
    localStream.addTrack(localStreamTracks[0].getTracks()[0]);
    localStream.addTrack(localStreamTracks[1].getTracks()[0]);
```

```JavaScript:Code(受信側)
    room.on('stream', async stream => {
      const audioCtx = new(window.AudioContext || window.webkitAudioContext);
      const dest = audioCtx.createMediaStreamDestination();
      const merger = audioCtx.createChannelMerger(stream.getTracks().length);
      stream.getTracks().forEach((track, index) => {
        const tmpStream = new MediaStream([track]);
        const mutedAudio = new Audio();
        mutedAudio.muted = true;
        mutedAudio.srcObject = tmpStream;
        mutedAudio.play();
        const source = audioCtx.createMediaStreamSource(tmpStream);
        source.connect(merger, 0, index);
      });
      merger.connect(dest);
      
      const newAudio = document.createElement('audio');
      await newAudio.play().catch(console.error);
    });
```

Chromeでもステレオで再生されました！

環境は
- 配信元: ChromeM86
- 配信先: ChromeM86
- room: mesh
- LR音源を複数トラックに分けて、受信側でステレオMIX

### 結論: LR音源を複数トラックに分けて、受信側でステレオMIXする方法は有効！！！

ただし...
SkyWayはマルチストリームに完全には対応していないので注意が必要です。
SFURoomではだめだったり、あとからRoomに入ってきた人は片方しか受け取れなかったりします。
なので、これもかなり限定的な用途でしか使えませんね。
参考) [FAQ: マルチストリームに対応していますか？](https://support.skyway.io/hc/ja/articles/900005467843)

多分、PeerConnection自体分けて送って合成すればいけるとは思います。

# 最後に
SkyWayでステレオ配信をするのであればやり方は下記2つくらいが考えられるかなぁという感じです。

1. MediaConnectionを使って音声だけ配信先にMeshにつないで、映像も必要なら別途Peerを作成してSFURoomで配信する
2. SFURoomを複数作成して、それぞれにLRの音源を送って、受信側でステレオMIXする。

配信規模的には2のほうが良さそうですが、Room分けちゃうので左右の遅延値のズレがどの程度でるかがきになるところ。
この点に関しては、別途、検証することがあれば公開します。