本記事は Advent Calendar 2021 「Unity #1」 7 日目の記事です。
【6 日】『【UnityC#】MonoBehaviourでもインターフェイスを利用して疎結合を実現する2つの方法』(@yutorisan さん)
【7 日】本記事
【8 日】『ゆるく使うUnityTest』(@am1tanaka さん)
はじめに
Unity でボイスチャットやテキストチャットを導入したい、となったとき Vivox は最有力候補の一つに挙がると思います。
今回はそんな Vivox でボイスチャット実装を試してみたので記事にしました。
(もう一つの主要な機能としてテキストチャットがありますが、ボイスチャットまででかなりのボリュームになってしまったので、別の機会にご紹介するつもりです。)
現実的なサービスの話となるとサーバー連携など色々あるかと思いますが、本記事ではクライアント実装にフォーカスしています。
また全機能について言及しているわけではなく、私が試せているボイスチャットの基本的な機能に絞っているので、その辺も合わせてご理解いただければと思います。
確認に用いたバージョン
- Unity 2020.3.23f1
- Vivox 5.15.0.uni.0
Vivox とは
Vivox 社が開発、提供しているボイスチャット、インスタントメッセージのミドルウェアおよびクラウドサービスです。
(なお Vivox 社は 2019 年に Unity に買収されています。1)
EVE Online、Overwatch、VALORANT、PUBG、League of Legends ... などなど、数多のゲームで採用されてきた実績があります。
もともと Unity に限らず様々なゲームエンジンに対応しており、クロスプラットフォームで動作するのが特長です。
ピーク時同時接続ユーザー数(以下 PCU) 5,000 まで無料で利用することができます。
どんなことができる?
ボイスチャット
ユーザー端末のマイク・スピーカーで複数人参加可能な音声通話ができます。
周辺との会話のような位置考慮タイプと無線通信のような位置無視タイプ、どちらもできます。
テキストチャット/インスタントメッセージ
複数人で任意の文章の送受信が可能です。
使いようによっては文章以外(スタンプなど)の送受信もできそうです。
TTS (Text To Speech)
テキストメッセージを合成音声に変換して送信できます。
現在 US 英語のみ対応。
STT (Speech To Text)
音声データから文字に起こして送信できます。できるみたいです。
ただし、早期リリース状態かつ有料サービスで利用には問い合わせが必要とのこと。2
対応プラットフォームは?
Linux を除き、おおよそ主要なプラットフォームに対応していると言ってよさそうです。
次のページで確認できます。
利用にあたっての注意点
アカウント取得に申請が必要
導入前にデベロッパーポータルからアカウントを取得する必要があるのですが、その際に名前や所在地等の情報を入力して申請する必要があります。
申請が承認されるまで待つ必要があるため、すぐに使えるようにはならない(リジェクトされる可能性もある?)ことを念頭に置いておきましょう。(タイミングもあると思いますが、私の場合は2~3日程度で承認されました。)
運用には別途承認が必要
試用や開発はサンドボックスモードで進められますが、運用前にはアプリを提出して運用認証資格を取得する必要があります。(私はそこまではやってません)
サンドボックスモードでは 100 PCU までの制限となります。
5,000 を超える PCU の場合は有料プラン
5,000 PCU を超えて使いたい場合は有料のプラン(エンタープライズサポートまたは専用ホスティング)にする必要があります。
PCU の状況に応じて月額で費用がかかるため、大きなタイトルなどでの利用を考えている場合は特にその点を検討する必要があります。
導入まで
次の記事を参考にさせていただくのがよいです。
利用申請が通るとアプリ用のキーなどの取得、SDK(サンプル付き)のダウンロード、フォーラムへの参加、運用認証資格取得申請などが可能になります。
キーと SDK を入手したらとりあえずサンプルを動かしてみるのがいいでしょう。
なお、SDK はアセットストアにも(おそらくデベロッパーポータルのと同じものが)あります。
Vivox アカウントを取得していなくても無料で入手できますので、動かせなくてもいいからとりあえず中身を確認したい、という人は入手してみてください。
実装
ふんわりと概要
Vivox にはチャンネルという概念があり、同チャンネル内のユーザー同士がチャット可能です。
チャンネルごとに位置を考慮するかしないかだったり、また音声かテキストかだったりと設定することができるので、この単位で様々なシチュエーションを実現していく感じです。
複数のチャンネルに同時参加することもできます。
非同期処理の設計について
ネットワークを経由したクラウドサービスの利用になりますので、必然的にプログラムは非同期的になります。
あらかじめ簡単に言及しておくと、Vivox の非同期処理はイベントハンドラによる実装となっています。
例えば、ログインセッションは ILoginSession、チャンネルセッションは IChannelSession など、INotifyPropertyChanged 派生となっており、プロパティ変更時に PropertyChanged イベントが呼ばれますので、これをハンドリングする形になります。
変更されたプロパティは PropertyChangedEventArgs.PropertyName に名前が入ってきますので、それを見て適切な処理を入れれば OK です。
例は後述するコードを見ていただければと思います。
コーディングサンプル
実装のコーディング例を載せます。適宜ドキュメントも参照していただければと思います。
注意: マイクのパーミッション
ボイスチャット機能は端末のマイクにアクセスします。
本記事では割愛しますが、プラットフォームによってはマイクにアクセスするためのパーミッションが必要になりますので、必要に応じて対応してください。
クライアントを作成
最初に Client を作成し、Client.Initialize で初期化します。
ゲーム開始時に動くコンポーネントの Awake 辺りで一度だけ実行するようにしておくのがいいと思います。
ここで引数に VivoxConfig を渡すことで設定をカスタマイズすることもできます。
また、アプリケーション終了時には初期化解除のメソッド Client.Uninitialize を呼びます。
// 以降のコードでは using VivoxUnity; を前提にします
private Client _client = null;
private void Awake()
{
_client = new Client();
_client.Initialize();
}
private void OnApplicationQuit()
{
if (_client != null)
{
_client.Uninitialize();
_client = null;
}
}
これにより DontDestroyOnLoad に「VivoxUnity.VxUnityInterop (Singleton)」というオブジェクトが常駐するようになります。
アカウント ID をつくる
次にプレイヤーに一意で紐づけられる AccountId を作成します。
デベロッパーポータルから取得する「発行者」と「ドメイン」はここで必要になります。
次のコードは ID 、表示名を引数に渡して AccountId を構築する例です。
/// <summary>
/// ドメイン
/// </summary>
[SerializeField]
private string _domain = "GET VALUE FROM VIVOX DEVELOPER PORTAL";
/// <summary>
/// 発行者
/// </summary>
[SerializeField]
private string _issuer = "GET VALUE FROM VIVOX DEVELOPER PORTAL";
private AccountId _accountId = null;
/// <summary>
/// アカウントを作成
/// </summary>
/// <param name="uniqueId"></param>
/// <param name="displayName"></param>
public void CreateAccount(string uniqueId, string displayName)
{
_accountId = new AccountId(_issuer, uniqueId, _domain, displayName);
}
ログイン/ログアウト
以降でデベロッパーポータルから取得する残り 2 つ「API エンドポイント」と「シークレットキー」が必要になります。
ログインは ILoginSession.BeginLogin を呼び、そのコールバック引数内で ILoginSession.EndLogin を呼び出す形になります。ログインに失敗した場合は EndLogin が例外を投げるようになっているので、try-catch します。
この Begin○○ ~ End○○ の形は他のリクエスト/レスポンス処理でも同様ですので、覚えておいてもらえればと思います。
要件がセキュアである場合はトークンを自前で生成することになると思いますが、とりあえずお試しなら ILoginSession.GetLoginToken というメソッドが使えます。
/// <summary>
/// API エンドポイント
/// </summary>
[SerializeField]
private string _server = "https://GETFROMPORTAL.www.vivox.com/api2";
/// <summary>
/// シークレットキー
/// </summary>
[SerializeField]
private string _tokenKey = "GET VALUE FROM VIVOX DEVELOPER PORTAL";
private ILoginSession _loginSession = null;
public void Login()
{
_loginSession = _client.GetLoginSession(_accountId);
Uri serverUri = new Uri(_server);
string token = _loginSession.GetLoginToken(_tokenKey, TimeSpan.FromSeconds(90d));
_loginSession.BeginLogin(serverUri, token, asyncResult =>
{
try
{
_loginSession.EndLogin(asyncResult);
}
catch (Exception e)
{
// ログインに失敗
return;
}
// ログインに成功
});
}
Vivox の機能の使用を終了するときは ILoginSession.Logout を呼び出してログアウトします。
public void Logout()
{
if (_loginSession == null || _loginSession.State == LoginState.LoggingOut || _loginSession.State == LoginState.LoggedOut)
{
Debug.LogWarning("ログインしていません。");
return;
}
_loginSession.Logout();
}
ログイン状態のハンドリング
さて、ログインからログアウトまでの最低限のコードは先述の通りですが、実用には Vivox サービス上でのログイン状態変化などをトリガに非同期的な処理を書く必要があると思います。
これを実現するにはプロパティ変更時のイベント PropertyChanged 内で ILoginSession.State をチェックして処理します。
…が少々躓きそうな点として、これは意図的なログアウト時には呼び出されないという仕様があります。
また、LoginState には LoginState.LoggingOut という列挙子がありますが、どうやらこのステートになることはないようでした。
これらのことを含めてコード例を書くと次のようになります。
public void Login()
{
_loginSession = _client.GetLoginSession(_accountId);
Uri serverUri = new Uri(_server);
string token = _loginSession.GetLoginToken(_tokenKey, TimeSpan.FromSeconds(90d));
_loginSession.PropertyChanged += OnLoginStateChanged;
_loginSession.BeginLogin(serverUri, token, asyncResult =>
{
try
{
_loginSession.EndLogin(asyncResult);
}
catch (Exception e)
{
_loginSession.PropertyChanged -= OnLoginStateChanged;
Debug.Log("TODO: ログインに失敗したときの処理をここに書く");
return;
}
});
}
public void Logout()
{
if (_loginSession == null || _loginSession.State == LoginState.LoggingOut || _loginSession.State == LoginState.LoggedOut)
{
Debug.LogWarning("ログインしていません。");
return;
}
_loginSession.Logout();
_loginSession.PropertyChanged -= OnLoginStateChanged;
Debug.Log("TODO: 意図したログアウトのときの処理をここに書く");
}
private void OnLoginStateChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs)
{
if (propertyChangedEventArgs.PropertyName != nameof(ILoginSession.State))
{
return;
}
if (sender is ILoginSession loginSession)
{
switch (loginSession.State)
{
case LoginState.LoggingIn:
Debug.Log("TODO: ログインを開始したときの処理をここに書く");
break;
case LoginState.LoggedIn:
Debug.Log("TODO: ログインが完了したときの処理をここに書く");
break;
case LoginState.LoggingOut:
// MEMO: ここに来ることはない?
break;
case LoginState.LoggedOut:
_loginSession.PropertyChanged -= OnLoginStateChanged;
Debug.Log("TODO: 切断等、意図しないログアウトのときの処理をここに書く");
break;
default:
break;
}
}
}
チャンネル参加/退出
先述の通りチャットの接続単位はチャンネルです。ということで ILoginSession からチャンネル名を指定して参加します(なければ勝手に作成してくれます)。
ログインと同様、お試し用のトークン生成メソッド IChannelSession.GetConnectToken があるので、それを使ったコード例を載せます。
public void JoinChannel(string channelName, ChannelType channelType)
{
if (_loginSession.State == LoginState.LoggedIn)
{
ChannelId channelId = new ChannelId(_issuer, channelName, _domain, channelType);
IChannelSession channelSession = _loginSession.GetChannelSession(channelId);
string token = channelSession.GetConnectToken(_tokenKey, TimeSpan.FromSeconds(90d));
// 1番目の引数は音声を使用するか、2番目の引数はテキストを使用するか
channelSession.BeginConnect(true, false, true, token, asyncResult =>
{
try
{
channelSession.EndConnect(asyncResult);
}
catch (Exception e)
{
channelSession.Parent.DeleteChannelSession(channelSession.Channel);
Debug.Log("チャンネル参加に失敗");
return;
}
});
}
else
{
Debug.LogWarning("ログインしていないためチャンネルに参加できません。");
}
}
チャンネルから退出するには IChannelSession.Disconnect を呼びます。
合わせてログインセッションにチャンネルが残らないよう ILoginSession.DeleteChannelSession で消しておきます。
ただ、これは後述するチャンネル参加状態のハンドリングで退出イベントを取れますので、不意の退出などを考慮するとそこでやった方がスマートな気がします。
public void LeaveChannel(string channelName)
{
var channelSession = _loginSession.ChannelSessions.FirstOrDefault(x => x.Channel.Name.Equals(channelName));
if (channelSession != null)
{
channelSession.Disconnect();
// ↓チャンネル退出時のイベントで呼ぶ
// channelSession.Parent.DeleteChannelSession(channelSession.Channel);
}
else
{
Debug.LogWarning("該当のチャンネルに参加していません。");
}
}
チャンネルタイプ
チャンネルを作成する際、次の 3 通りのタイプから選択します。
- ポジショナルチャンネル(3D チャンネル)
- ノンポジショナルチャンネル(2D チャンネル)
- エコーチャンネル
これはチャンネル生成時にコンストラクタの引数で設定します。
// ポジショナルチャンネル
ChannelId positionalChannelId = new ChannelId(_issuer, "positional-channel-test", _domain, ChannelType.Positional);
// ノンポジショナルチャンネル
ChannelId nonPositionalChannelId = new ChannelId(_issuer, "nonpositional-channel-test", _domain, ChannelType.NonPositional);
// エコーチャンネル
ChannelId echoChannelId = new ChannelId(_issuer, "echo-channel-test", _domain, ChannelType.Echo);
ポジショナルチャンネル(3D チャンネル)
位置関係を考慮するチャットチャンネル。話し手の位置と聞き手の位置&向きを持たせることで、可聴範囲内のユーザーのみが接続されるようになり、位置関係に応じて音量が変化します。
MMO やメタバースなどでアバターの位置に応じて会話する場合などがユースケースだと思います。
可聴距離などのパラメータはコンストラクタの引数で Channel3DProperties を渡せますので、それでカスタマイズできます。
位置関係の更新は IChannelSession.Set3DPosition で行います。
/// <summary>
/// 聞くときの位置定義
/// </summary>
[SerializeField]
private Transform _ear = null;
/// <summary>
/// しゃべるときの位置定義
/// </summary>
[SerializeField]
private Transform _mouth = null;
private void LateUpdate()
{
if (_loginSession != null)
{
// MEMO: 本来は毎フレーム呼ぶようなことはしない
foreach (var channelSession in _loginSession.ChannelSessions.Where(x => x.Channel.Type == ChannelType.Positional && x.AudioState == ConnectionState.Connected))
{
channelSession.Set3DPosition(_mouth.position, _ear.position, _ear.forward, _ear.up);
}
}
}
トラフィックが発生しますので、実際に使う際は毎フレーム呼ぶといったようなことはせず、「位置が変化したときのみ」とか「何秒かに1回」といった制限を設けるのが良いかと思います。
なお、Get3DPosition のようなメソッドは存在せず、自分や他のチャンネル参加者がどういった位置を設定しているのかを直接知ることはできないようです。
おそらく計算はサーバーで完結している(クライアントは音量として結果を受け取るのみ)と思われるので、そういうものなのでしょう。
補足: 座標系について
とのことなので、いつも通りの forward と up を渡しましょう。
ノンポジショナルチャンネル(2D チャンネル)
位置関係を考慮しないチャットチャンネル。ユースケースとして無線通信などが想定されます。
チャンネルに入ってさえいれば、固定の音量で会話できます。
ドキュメントでは
注:ポジショナルチャンネルとノンポジショナルチャンネルの両方を操作するときは、最初にポジショナルチャンネルに参加してから、ノンポジショナルチャンネルに参加する必要があります。
とありますので、ポジショナルチャンネルと併用する場合は参加の順番に注意した方がよさそうです。
エコーチャンネル
このタイプのチャンネルでは自身の音声が返ってきます。マイクのテストなどで使用できます。
チャンネル参加状態のハンドリング
チャンネルセッションにおいて重要なのはチャンネルに接続しているかどうかを表す ChannelState(もしくは AudioState と TextState)のプロパティ変更になります。
安定して動作させるためにも、これを適切にハンドリングする必要があります。
やり方はログインのときと似たような感じです。
// --- 前後省略 ---
// チャンネル作成時に次のようにイベントを登録
channelSession.PropertyChanged += OnChannelStateChanged;
// --- 前後省略 ---
private void OnChannelStateChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs)
{
if (sender is IChannelSession channelSession)
{
if (propertyChangedEventArgs.PropertyName == nameof(IChannelSession.ChannelState))
{
switch (channelSession.ChannelState)
{
case ConnectionState.Disconnected:
channelSession.PropertyChanged -= OnChannelStateChanged;
channelSession.Parent.DeleteChannelSession(channelSession.Channel);
Debug.Log(channelSession.Channel + "から退出しました。");
break;
case ConnectionState.Connecting:
Debug.Log(channelSession.Channel + "に参加開始しました。");
break;
case ConnectionState.Connected:
Debug.Log(channelSession.Channel + "に参加しました。");
break;
case ConnectionState.Disconnecting:
Debug.Log(channelSession.Channel + "から退出開始しました。");
break;
default:
break;
}
}
}
}
チャンネル参加者の情報
IChannelSession.Participants でチャンネル内のユーザー情報(IParticipant)を取得できます。
private void LateUpdate()
{
var channelSessions = _loginSession?.ChannelSessions;
if (channelSessions != null)
{
foreach (var channelSession in channelSessions)
{
foreach (var participant in channelSession.Participants)
{
Debug.Log(channelSession.Channel.Name + "に" + participant.Account.DisplayName + "が参加中。");
}
}
}
}
チャンネル参加者のイベント
他の人がチャンネルに参加/退出したときや、何か話しているのを検知したときなどを IChannelSession.Participants にあるイベントハンドラによって処理できます。
- ユーザーがチャンネルに参加するとき → IChannelSession.Participants.AfterKeyAdded
- ユーザーがチャンネルから退出するとき → IChannelSession.Participants.BeforeKeyRemoved
- ユーザーの状態に関する重要な変更(話し中など)があるとき → IChannelSession.Participants.AfterValueUpdated
// --- 前後省略 ---
// チャンネル作成時に次のようにイベントを登録
channelSession.Participants.AfterKeyAdded += OnParticipantJoined;
channelSession.Participants.BeforeKeyRemoved += OnParticipantLeft;
channelSession.Participants.AfterValueUpdated += OnParticipantSpeechChanged;
// --- 前後省略 ---
private void OnParticipantJoined(object sender, KeyEventArg<string> keyEventArg)
{
if (sender is VivoxUnity.IReadOnlyDictionary<string, IParticipant> source)
{
var participant = source[keyEventArg.Key];
Debug.Log(participant.Account.DisplayName + "が参加しました。");
}
}
private void OnParticipantLeft(object sender, KeyEventArg<string> keyEventArg)
{
if (sender is VivoxUnity.IReadOnlyDictionary<string, IParticipant> source)
{
var participant = source[keyEventArg.Key];
Debug.Log(participant.Account.DisplayName + "が退室しました。");
}
}
private void OnParticipantSpeechChanged(object sender, ValueEventArg<string, IParticipant> valueEventArg)
{
if (sender is VivoxUnity.IReadOnlyDictionary<string, IParticipant> source)
{
if (valueEventArg.PropertyName == nameof(IParticipantProperties.SpeechDetected))
{
var participant = source[valueEventArg.Key];
if (valueEventArg.Value.SpeechDetected)
{
Debug.Log(participant.Account.DisplayName + "が話しています。");
}
else
{
Debug.Log(participant.Account.DisplayName + "が話すのをやめました。");
}
}
}
}
動作確認
これらの実装ができましたら、お手持ちのスマホなどにビルド・インストールするなどして、
エディタや複数の端末から同じチャンネルに接続してみてください。
一方に話しかけてみて、もう片方から自分の声が聞こえてくれば成功です!👏
ミュートや音量調整、ブロックなどももちろんできるので、ドキュメントを見ながらお好みのボイスチャットをつくってみてください。
おわりに
お疲れ様でした。ここまで読んでくださってありがとうございました。
まだ Vivox ボイスチャットのさわりくらいの内容なのですが、想定を超えたボリュームになってしまい、あまりきれいにまとめられなかったと思います。
しっかりしたドキュメントがあり、サンプルは多少自力で読み解く必要はあるものの始めやすいと感じました。
テキストチャットやその他の機能についても色々試せてはいるので、別途書く機会ができれば…と思います。
それでは良い Vivox ライフを!👋