WebRTCのリアルタイム音声合成サーバ(MCU)を作ってみた

12 min read読了の目安(約11600字

概要

NTTコミュニケーションズ、SkyWayチーム新入社員のshinyoshiakiです。

この記事は NTTコミュニケーションズ Advent Calendar 2020 の18日目の記事です。
昨日はnao_takemuraさんの記事、 「Noodl で IoT を爆速でプロトタイピングする」でした。

本記事ではWebRTCのMediaChannelの音声をサーバサイドでリアルタイムに合成するMCUを作っていきます

MCU(Multipoint Control Unit)とは?

WebRTCを用いた多人数通信ではP2PのMesh型トポロジーだとあまりスケールしない(ユーザーの計算資源と上り下りの通信帯域を食いつぶす)のでサーバを経由させて通信や処理の効率を図ることが一般的に行われています。

この経由させるサーバは主にSFUMCUの2種類です。
SFU(Selective Forwarding Unit)はクライアントから来るデータを別のクライアントに受け流す中継点であるのに対して、MCU(Multipoint Control Unit) はクライアントから来るデータ(音声 or 映像)をサーバ側で合成してクライアント側に流しています。

SFUとMCUを比較した主なメリデメを表にまとめると

SFU MCU
通信量 多い (上りは一本、下りは人数分) 少ない (人数に関わらずほぼ一定)
サーバ負荷 低い (暗号解除→中継→暗号化) 高い (暗号解除→デコード→合成→エンコード→暗号化)
遅延 少ない (暗号解除→中継→暗号化) 多い (暗号解除→デコード→合成→エンコード→暗号化)

この他にもMCUで映像を合成する場合、映像のレイアウトに柔軟性がなく(出力が一つの映像となるため)、フロントエンド側からするとSFUのように個別の映像が貰えるほうが自由度が高くて嬉しいという観点もあります。

ただ、音声の場合、Web会議的なユースケースにおいてはレイアウトの概念がなくPCのスピーカーから出てくる頃にはMCUとSFUで音の違いはほとんどないでしょう。また、映像に比べると音声の合成はそれほどサーバへの負荷が高くありません。

音声にMCUを使う嬉しい点として、SFU経由だと多人数Web会議での通話が成り立たないほど回線が細い場合(移動通信回線の通信制限状態など)でもMCU経由だと通信量が抑えられるので通話できるかもしれないということが挙げられます。

そこで本記事では比較的実用的と思われる音声のみを扱うMCUを実際に作ってみます。

MCUを作ってみよう

ブラウザから複数の音源をMCUサーバに送信し、MCUサーバが複数の音源を一つの音声に合成してブラウザに送り返すサンプルアプリを作っていきます!

使用ライブラリ

サーバサイドでWebRTCを扱うのでWebRTCの非ブラウザ実装を用いる必要があります。有名所としてlibwebrtc(C++)、pion(Go)、aiortc(Python)などのライブラリが挙げられますが、今回は著者謹製のwerift(Node.js,TypeScript)を使います。

開発にする使用する言語はTypeScriptで環境はNode.js v14以上です。

成果物

リンク

github.com/shinyoshiaki/werift-webrtc/tree/master/examples/mcu

操作方法

サーバ側

https://github.com/shinyoshiaki/werift-webrtc をクローン

yarn install
yarn ts-node-dev examples/mcu/server.ts

ブラウザ側

github.com/shinyoshiaki/werift-webrtc/blob/master/examples/mcu/client.html をブラウザで開く

動作している様子

↓動画から音が出ます!

複数入力した音声ファイルがサーバ側でリアルタイムに合成されて返却されブラウザから音が出ています

実装

システム構成の雰囲気について図にしました。

サーバ側とクライアント側についてそれぞれ実装していきます。

サーバ側

1. 初期処理

ブラウザ上のconnect serverを押すとwebsocketのコネクションが張られ、サーバ側の初期処理が走ります。

examples/mcu/server.ts
  const encoder = new OpusEncoder(48000, 2);
  const mixer = new Mixer();
  const pc = new RTCPeerConnection({
    stunServer: ["stun.l.google.com", 19302],
  });
  const sender = pc.addTransceiver("audio", "sendonly");
  await pc.setLocalDescription(pc.createOffer());
  send("offer", { sdp: pc.localDescription });

Opusのエンコーダ/デコーダの生成(encoderという変数名ですがencode/decodeを両方行います)、音声を合成するためのミキサーの生成、WebRTCのRTCPeerConnectionの生成を行っています。また、MCUサーバからクライアントへ音声を送信する用のsendonlyなTransceiverを予め1本作っておきます。

最後にofferのSDPをクライアント側に送信しています。

ちなみにOpusのライブラリには@discordjs/opusを使っています

2. クライアントからの制御

クライアント - MCU間の制御を行うためにwebsocketを使っています。

examples/mcu/server.ts
const tracks: {
  [msid: string]: RtpTrack;
} = {};
const disposers: {
  [msid: string]: () => void;
} = {};

socket.onmessage = async (ev) => {
  const { type, payload } = JSON.parse(ev.data as string);
  console.log("onmessage", type);
  switch (type) {
    case "answer":
      {
        const { sdp } = payload;
        pc.setRemoteDescription(sdp);
      }
      break;
    case "candidate":
      {
        const { candidate } = payload;
        pc.addIceCandidate(candidate);
      }
      break;
    case "publish":
      {
        const transceiver = pc.addTransceiver("audio", "recvonly");
        transceiver.onTrack.once((track) => {
          tracks[transceiver.msid] = track;
        });
        send("onPublish", { id: transceiver.msid });
        await pc.setLocalDescription(pc.createOffer());
        send("offer", { sdp: pc.localDescription });
      }
      break;
    case "add":
      {
        const { id } = payload;
        const track = tracks[id];
        const input = mixer.input();
        const { unSubscribe } = track.onRtp.subscribe((packet) => {
          const decoded = encoder.decode(packet.payload);
          input.write(decoded);
        });
        disposers[id] = () => {
          unSubscribe();
          input.remove();
        };
      }
      break;
    case "remove":
      {
        const { id } = payload;
        disposers[id]();
        delete disposers[id];
      }
      break;
  }
};

switch文中のanswercandidateはよくあるシグナリングの処理なので割愛するとして、publish,add,removeで何をしているか見ていきます。

publish

ブラウザ上のファイルを選択からファイルを選択すると選ばれたファイルがサーバ側にPublishされます。

examples/mcu/server.ts
      case "publish":
        {
          const transceiver = pc.addTransceiver("audio", "recvonly");
          transceiver.onTrack.once((track) => {
            tracks[transceiver.msid] = track;
          });
          send("onPublish", { id: transceiver.msid });
          await pc.setLocalDescription(pc.createOffer());
          send("offer", { sdp: pc.localDescription });
        }
        break;

クライアントからPublishメッセージが飛んできたらサーバは

  1. クライアントから音声を受け取るためのrecvonlyなTransceiverを作る
  2. TransceiverはTrackが生成されたらtracksオブジェクトにTrackを保管する
  3. TransceiverのmsidをidとするonPublishメッセージをクライアントに送る
  4. OfferのSDPをクライアントに送る
add
examples/mcu/server.ts
  case "add":
    {
      const { id } = payload;
      const track = tracks[id];
      const input = mixer.input();
      const { unSubscribe } = track.onRtp.subscribe((packet) => {
        const decoded = encoder.decode(packet.payload);
        input.write(decoded);
      });
      disposers[id] = () => {
        unSubscribe();
        input.remove();
      };
    }
    break;

クライアントからAddメッセージが飛んできたらサーバ側は

  1. ミキサーのInputを作る
  2. trackからrtpのパケットを取り出し、OpusのデコーダでRTPのペイロードに入っているOpusのバイナリをPCMに変換する
  3. PCMをミキサーのInputで書き込む
  4. ミキサーから音源を消すremove処理用のDisposerを用意する

を行います

remove
examples/mcu/server.ts
  case "remove":
    {
      const { id } = payload;
      disposers[id]();
      delete disposers[id];
    }
    break;

クライアントからRemoveメッセージが飛んできたらサーバ側は、先程addで用意したDisposerを使って対象の音源の停止処理をします。
停止処理が完了すると対象の音源のミキサーへのInputが削除され、クライアントから流れている音から今停止された音源が取り除かれます。

3. ミキサーの処理

先程input.write()で何気なくミキサーにPCM音源を書き込んでいい感じに合成していましたが中で何をしているか覗いてみましょう

examples/mcu/mixing.ts
write(id: string, buf: Buffer) {
  this.pcm[id] = buf;
  this.merge();
}

private merge() {
  if (Object.keys(this.pcm).length >= Object.keys(this.inputs).length) {
    const inputs = Object.values(this.pcm);
    const base = inputs.shift();
    this.pcm = {};
    const res = inputs.reduce(
      (acc: number[], cur) => {
        const next = acc.map((v, i) => this.mix(v, cur[i]));
        return next;
      },
      [...base]
    );
    this.onData(Buffer.from(res));
  }
}

private mix(a: number, b: number) {
  const res = a + b;
  const max = 1 << (16 - 1);
  if (max < res) {
    return max;
  } else if (0 > res) {
    return res;
  }
  return res;
}

mergeメソッドで複数のインプット(PCM音源)を重ね合わせています。reduceで書くとスッキリと書けました。
実際にPCMを合成しているのはmixメソッドです。16bitから溢れないようにしています。

4. クライアントへ音声を送信

最後に合成した音声をクライアントへ送信します。

examples/mcu/server.ts
let sequenceNumber = random16();
let timestamp = random32();
mixer.onData = (data) => {
  const encoded = encoder.encode(data);

  sequenceNumber = uint16Add(sequenceNumber, 1);
  timestamp = uint32Add(timestamp, 960n);

  const header = new RtpHeader({
    sequenceNumber,
    timestamp: Number(timestamp),
    payloadType: 96,
    extension: true,
    marker: false,
    padding: false,
  });
  const rtp = new RtpPacket(header, encoded);
  sender.sendRtp(rtp);
};

今回使用しているWebRTCライブラリのweriftはtransceiverから任意のRTPパケットを送信するsendRtp機能があるのでそれを利用します。

RTPを送るためにRTPパケットを作る必要があるのでここでRTPパケットを組み立てています。
RTPのHeader部のtimestampの刻み幅にはOpusの周波数とフレームサイズを元に導出(48kHz × 20ms = 960)した値を用いています。
ブラウザはこのtimestampの値を音の再生に使っているので刻み幅が大きいとスローモーションに、小さいと速くなります。
payloadTypeにはweriftがopusを96に割り当てているので96を入れます

最後にsendRtpで組み立てたRTPパケットを送信するとクライアントのブラウザから音が出ます。

ブラウザ側

ブラウザ側はこれといって特殊なことはしていないのでコードの一部だけ載せておきます。

examples/mcu/client.html
const peer = new RTCPeerConnection({
  iceServers: [],
});
let socket;
let trackBuffer;

const App = () => {
  const remoteRef = React.useRef();
  const [published, setPublished] = React.useState([]);
  const [mixing, setMixing] = React.useState([]);

  const connect = async () => {
    socket = new WebSocket("ws://localhost:8888");          
    socket.onmessage = async (ev) => {
      const { type, payload } = JSON.parse(ev.data);            
      switch (type) {
        case "offer":
          {
            const { sdp } = payload;
            await peer.setRemoteDescription(sdp);
            if (peer.getTransceivers().length >= 2 && trackBuffer) {
              const transceiver = peer.getTransceivers().slice(-1)[0];
              transceiver.sender.replaceTrack(trackBuffer);
              transceiver.direction = "sendonly";
              trackBuffer = undefined;
            }
            await peer.setLocalDescription(await peer.createAnswer());
            send("answer", { sdp: peer.localDescription });
          }
          break;
        case "onPublish":
          {
            const { id } = payload;
            setPublished((prev) => [...prev, id]);
          }
          break;
      }
    };

    peer.onicecandidate = ({ candidate }) => {
      if (candidate) send("candidate", { candidate });
    };
    peer.ontrack = (e) => {
      console.log("ontrack");
      remoteRef.current.srcObject = e.streams[0];
    };
  };

  const publishFile = async ({ target: { files } }) => {
    const file = files[0];
    const stream = await getAudioStream(await file.arrayBuffer());
    trackBuffer = stream.getTracks()[0];
    send("publish", {});
  };

  const add = (id) => {
    send("add", { id });
    setPublished((prev) => prev.filter((v) => v !== id));
    setMixing((prev) => [...prev, id]);
  };

  const remove = (id) => {
    send("remove", { id });
    setMixing((prev) => prev.filter((v) => v !== id));
    setPublished((prev) => [...prev, id]);
  };

  return (
    <div>
      <button onClick={connect}>connect server</button>
      <input type="file" onChange={publishFile} />
      <audio ref={remoteRef} autoPlay />
      <div>
        <p>published</p>
        {published.map((id) => (
          <div key={id}>
            {id}
            <button onClick={() => add(id)}>add</button>
          </div>
        ))}
      </div>
      <div>
        <p>mixing</p>
        {mixing.map((id) => (
          <div key={id}>
            {id}
            <button onClick={() => remove(id)}>remove</button>
          </div>
        ))}
      </div>
    </div>
  );
};

まとめ

本記事では、シンプルなMCUのサンプルを作ってみました。
今回はクライアントからの複数の音源入力をサーバサイドで合成して返しているだけなのですが、これをベースとして追加の実装をしていけばMCUによる多人数音声Web会議システムなんかを作れると思います。冬休みの暇つぶしにちょうど良いかもしれませんね。

明日は

明日の NTTコミュニケーションズ Advent Calendar 2020 の担当は @kanatakita さんの記事です!