1
Help us understand the problem. What are the problem?

posted at

updated at

[実装編.Pt1]VRChatにリアルタイムかつ高品質なDJ&VJを配信する仕組みを実装する

この記事は、 ライブ配信/ビデオ通話SDK(Agora)を使用したサービスのアイデアを大募集!【PR】V-CUBE Advent Calendar 2021 の10日目の記事です。

前回に続いて、今回は実装編パート1を書いてみました。一気に全部実装できなかったので、実装編は2回に分けて提供します。次回の記事は、アドベントカレンダー終了後に書きます。

今回実装する部分

前回載せたシステム概要図 の、↓の部分を実装していきます。

スクリーンショット 2021-12-15 22.00.08.png

VJがAgoraにストリームを打ち上げて、VRChatのワールドのプレーヤで再生する部分は次回実装していきます。

今回実装したコード

schutzstaffel-prototype

に載せています。

実装

1. DJの音声をキャプチャする部分

navigator.mediaDevices.getDisplayMedia()でDJソフトを画面キャプチャし、そこから音声のトラックを抜き出す、という方法でDJ音声をキャプチャします。

dj-app.js
const audioStream = await navigator.mediaDevices.getDisplayMedia({
  audio: true,
  // video: falseにするとうまくストリームが取れないので、trueに
  video: true,
});
const audioTracks = audioStream.getAudioTracks();

ちなみに上記で取得したオーディオトラックの再生をためしてみたい場合は、下記のようにすればできます。

dj_app.js
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(audioStream);
// AudioContextはBaseAudioContext(https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext)
// を継承していて、その中のdestinationプロパティには今選択されている
// オーディオデバイスがデフォルトで入っている。
source.connect(audioCtx.destination);

その後、Agoraにチャンネルに対してpublishするため、まずはクライアントを作成します。このとき、ライアルタイムで通信したいので、mode部分には live ではなく rtc を指定します。

agora.js
...
this.client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
...

その後、Agoraで任意のチャンネルを作成し、そのチャンネルに対して音声トラックのみpublishします。

dj_app.js
...
await this.agora.joinChannel(channelName);
await this.agora.publishAudio();
...

this.agora.publishAudio() は、先述のリポジトリに書いてあるとおりですが、内部的には以下のような挙動をしています。

dj_app.js
...
// 音声だけ送りたいので、ビデオトラックは指定しない
const agoraAudioTrack = AgoraRTC.createCustomAudioTrack({
  // さっき取得したオーディオトラック
  mediaStreamTrack: this.audioTrack,
});
this.client.publish([agoraAudioTrack]);

...

これで、DJ側の最低限の処理は完了です。

2. Agora.ioのトークンを返すAPIを作成

Agora.ioのCertificateやチャンネル名を元に、アクセストークンを発行する簡易的なAPIを発行します。

const express = require('express');
const { RtcTokenBuilder, RtcRole } = require('agora-access-token');
const PORT = 8081;
const APP_ID = process.env.APP_ID;
const APP_CERTIFICATE = process.env.APP_CERTIFICATE;

const app = express();

const nocache = (req, resp, next) => {
  resp.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
  resp.header('Expires', '-1');
  resp.header('Pragma', 'no-cache');
  next();
};

const generateAccessToken = (req, resp) => {
  resp.header('Access-Control-Allow-Origin', '*');
  const channelName = req.query.channelName;
  if (!channelName) {
    return resp.status(500).json({ 'error': 'channel is required' });
  }
  let uid = req.query.uid;
  if(!uid || uid == '') {
    uid = 0;
  }
  const role = RtcRole.PUBLISHER;
  // get the expire time
  const expireTime = 3600;
  const currentTime = Math.floor(Date.now() / 1000);
  const privilegeExpireTime = currentTime + expireTime;
  const token = RtcTokenBuilder.buildTokenWithUid(
    APP_ID,
    APP_CERTIFICATE,
    channelName,
    uid,
    role,
    privilegeExpireTime);
  return resp.json({ 'token': token });
};
app.get('/access_token', nocache, generateAccessToken);
app.listen(PORT, () => {
  console.log(`Listening on port: ${PORT}`);
});

ここまで実装した上で、DJ側のクライアントを動かしてみましょう。
まずはプロジェクトのルートで下記コマンドを実行し、APIサーバを動かしてください。

yarn run start

その後、transporter ディレクトリで以下を実行し、DJクライアントを起動しましょう。

yarn run start

http://localhost:8080/dj.html にアクセスします。すると navigator.mediaDevices.getDisplayMedia が自動的に起動するので、オーディオをキャプチャしたいウィンドウを選択してください。今回はフリー素材の音声をブラウザのタブ上で開いたので、それを選択しています。

スクリーンショット 2021-12-15 21.48.00.png

うまくいくと、以下のように「接続成功」と表示され、チャンネルのUIDが表示されます。

スクリーンショット 2021-12-15 21.48.31.png

3. DJから音声を受け取って、VJ映像とmixする部分

まずは、 navigator.mediaDevices.getDisplayMedia() で、キャプチャしたいVJのウィンドウの映像ストリームを取得する処理を書きます。

vj_app.js
this.videoStream = await navigator.mediaDevices.getDisplayMedia({
  // 音声はいらないのでミュート
  audio: false,
  video: true,
});
const videoTracks = this.videoStream.getVideoTracks();

次に、DJから受け取った音声を取得します。

agora.js
...
// AgoraのClient
this.client.on("user-published", this.handleUserPublished);
...
vj_app.js
...
  async onReceiveRemoteStream(e) {
    if (!this.dom) {
      return;
    }
    if (e.detail.mediaType === 'audio') {
      const remoteAudioTrack = e.detail.user.audioTrack;
      this.audioTrack = remoteAudioTrack.getMediaStreamTrack();
      this.dom.$receivedAudioTrack.innerText = `受け取った音声: ${remoteAudioTrack._uintId}`;
      this.agora.setAudio(this.audioTrack);
      // プレビュー確認用に、音声トラック追加
      this.videoStream.addTrack(this.audioTrack);
    }
  }
...

その後、受け取った音声と、キャプチャした映像を一つにmixし、同じAgoraのチャンネルに対して打ち上げます。

agora.js
...
    const localTracks = {
      videoTrack: AgoraRTC.createCustomVideoTrack({
        mediaStreamTrack: this.videoTrack,
      }),
      audioTrack: AgoraRTC.createCustomAudioTrack({
        mediaStreamTrack: this.audioTrack,
      }),
    };
    this.client.publish(Object.values(localTracks));
...

実際に動かしてみた

さて、主要な処理のみご紹介しましたが、これでDJからVJまでの通信経路が完成しました。VJ側のクライアントも動かしてみます。

VJソフトは、Macに対応している VDMX の無料版を使っていきます。ちなみに普段自分はメイン機のWindowsでResolume Avenueを使っていますが、諸事情で今Macで開発しているのでVDMXで検証してみました。

http://localhost:8080/vj.html にアクセスしてくださいすると、下記のようにウィンドウを選択する画面が表示されるので、VJ映像をキャプチャしたいウィンドウを選択してください。今回は、VDMXのoutputのウィンドウをキャプチャしました。

スクリーンショット 2021-12-15 21.49.19.png

うまくキャプチャできると、下記のようにプレビューに映像が表示されます。

スクリーンショット 2021-12-15 21.49.51.png

その後、DJ側が作成したroomの名前を入力し、「ルームにjoin」をクリックします。すると、
DJ側から受け取った音が自動で再生され、VJ映像とmixしつつ同じチャンネルに打ち上げを開始します。

無事に、打ち上げが成功するところまで確認できました。

DJとVJのクライアントをそれぞれ違うマシン、違うネットワーク同士で接続して見ましたが、DJからVJへの配信遅延はAgoraのおかげで1秒以内で実現できました。

まとめ

DJとVJ間の通信クライアントをweb上に構築しました。
DJとVJの映像を無事合体させることが確認できたので良かったです。あと、超低遅延(1秒以内)で安定して使えそうなこともわかりました。

さて、しかし今回のこのやり方だと、ウィンドウに映像を出力しないとキャプチャすることができません。
今回はこの実装にとどめますが、ウィンドウへの出力に対応していないVJソフトもあるので、仮想カメラなどに対応するなどして画面に出力せずとも映像をキャプチャできるようにしたいですね。

次回

いよいよVJが打ち上げた映像を、UnitySDKを使ってVRChatのワールドで受け取っていきます。

参考文献

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
1
Help us understand the problem. What are the problem?