Azure Communication Servicesの技術検証

はじめに

こんにちは。HoloLensチームの野元です。
本記事は、Azure Communication Services(以降、ACSと称します)を使って、HoloLens 2とPC間でビデオ通話することを目的として実施した調査・検証結果をまとめたものです。

ACSの概要

ACSとは

音声・ビデオ・チャットなどのリアルタイム通信ができるAzureサービスのことです。https://docs.microsoft.com/ja-jp/azure/communication-services/overview

価格

ビデオ通話の利用には1ユーザーあたり約0.5円/分の費用が掛かります。(記事執筆時点)https://azure.microsoft.com/ja-jp/pricing/details/communication-services/

ACSのビデオ通話に使用するSDK

適宜、アプリケーションに次のSDKを導入します。

Azure.Communication.Identity

クライアントの認証、ユーザーIDの作成、アクセストークンの発行をする機能を提供します。

Azure.Communication.Calling

ビデオ通話に関する機能を提供します。

ACSのビデオ通話に必要なもの

1. ACSのAzureリソース(Communication Services)

ACSのサービスを利用する上で必要となるため、下記の手順を参考にしてCommunication Servicesのリソースを作成します。

2. ユーザーアクセストークン

ACSのCalling SDKを使ったビデオ通話をする上で、ユーザーアクセストークンが必須となります。
Identity SDKを使うことで、クライアントの認証→ユーザーIDの作成→アクセストークンの発行が可能となります。アクセストークンは、各クライアントごとに必要であり、有効期限は発行から1日間です。
下記にアクセストークンの発行方法を示します。

Azure Portalからアクセストークンを発行する方法

クイック スタート – テスト用の Azure Communication Services アクセス トークン をすばやく作成する

プログラムを書いてアクセストークンを発行する方法

クイック スタート:アクセス トークンを作成して管理する

3. 通信先のユーザーIDもしくはグループID

1対1のビデオ通話

通信先のユーザーIDが必要です。(ユーザーIDとはアクセストークンの発行の際に使用されるもののことです)

グループビデオ通話

グループIDが必要です。(グループIDはGuidでよしなに生成できます)

ACSのビデオ通話検証

検証環境

  • Windows 10
  • Visual Studio 2022
  • Unity 2021.3.2f1

システム構成

次の3つのアプリケーション間でグループビデオ通話を行うことで、HoloLens 2とPC間でのビデオ通話が可能となります。

HoloLens 2で動かすアプリケーション

  • Unityアプリケーション
    • 3Dオブジェクト込みのビデオ通話を担当
  • 2DUWPアプリケーション
    • PCのビデオ映像のレンダリングを担当

PCで動かすアプリケーション

  • 2DUWPアプリケーション
    • HoloLens 2とのビデオ通話を担当

注意事項(記事執筆時点)

  • Unityではビデオ映像のレンダリングができません。一方で、2DUWPアプリではビデオ映像のレンダリングができます。そのため、Unityと2DUWPのアプリケーションを併用することで、リモートクライアントの映像をHoloLens 2でも描画することが可能となります。
  • ACSではスピーカーを制御する機能が提供されていません。そのため、1つのデバイスで2つのACSアプリを起動して同一のグループビデオ通話に入ると、自分が話した音声がスピーカーからも聞こえる現象が発生します。
  • HoloLens 2で表示している3Dオブジェクト込みのビデオ通話を行うには、Unityを使用する必要があります。ただし、転送するビデオ映像には3Dオブジェクトの表示が強制されます。
  • SDKはUnity Packageが用意されていないため、Nuget Packageから必要なものをよしなにUnityに取り込む必要があります。次にCalling SDKのUnityへの導入方法を示します。

UnityへのCalling SDKの導入方法

ダウンロードしたSDKの拡張子を.nupkg→.zipに変更後、下記のファイルを取り出しUnityにインポートします。下記に太字でUnityにインポートする際の階層例を示します。

  • Assets\Plugins\azure.communication.calling.1.0.0-beta.31
    • azure.communication.calling.1.0.0-beta.31\lib\uap10.0配下の.winmdをすべてインポートします。
    • ARM64
      • azure.communication.calling.1.0.0-beta.31\runtimes\win10-arm64\native配下の.dllをすべてインポートし、各.dllのプラットフォームのCPU設定をARM64にします。
    • x64
      • azure.communication.calling.1.0.0-beta.31\runtimes\win10-x64\native配下の.dllをすべてインポートし、各.dllのプラットフォームのCPU設定をX64にします。
    • x86
      • azure.communication.calling.1.0.0-beta.31\runtimes\win10-x86\native配下の.dllをすべてインポートし、各.dllのプラットフォームのCPU設定をX86にします。

Unityでのグループビデオ通話の実装例

下記のようなスクリプトを用意し、初期化処理(InitializeAsync())を実行した後に、グループビデオ通話の開始(GroupCallButton_ClickAsync())を行うことで、複数クライアント間でのグループビデオ通話が可能となります。もちろん、音声のみの通話も可能です。

#if WINDOWS_UWP
using Azure.WinRT.Communication;
using Azure.Communication.Calling;
#endif
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Nextscape.AzureCommunicationServices
{
/// <summary>
/// AzureCommunicationServicesのCalling SDKの機能を提供します。
/// </summary>
public class ACSCalling
{
#if WINDOWS_UWP
private CallMode _callMode = CallMode.AudioAndVideo;
private CallClient _callClient;
private CallAgent _callAgent;
private Call _call;
private DeviceManager _deviceManager;
private LocalVideoStream[] _localVideoStream;
private Dictionary<string, RemoteParticipant> _remoteParticipantDictionary;
/// <summary>
/// 初期化処理を実行する
/// </summary>
public async Task InitializeAsync(CallMode callMode, string accessToken, string userName)
{
_callMode = callMode;
_callClient = new CallClient();
if (_callMode == CallMode.AudioAndVideo)
{
_deviceManager = await _callClient.GetDeviceManager();
_localVideoStream = new LocalVideoStream[1];
}
var token_credential = new CommunicationTokenCredential(accessToken);
var callAgentOptions = new CallAgentOptions()
{
DisplayName = userName
};
_callAgent = await _callClient.CreateCallAgent(token_credential, callAgentOptions);
if (_callMode == CallMode.AudioAndVideo) _callAgent.OnCallsUpdated += Agent_OnCallsUpdated;
_callAgent.OnIncomingCall += Agent_OnIncomingCall;
}
/// <summary>
/// 1対1のビデオ通話を開始する
/// </summary>
public async Task CallButton_ClickAsync(string destinationUserId)
{
if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();
var startCallOptions = new StartCallOptions();
if (_callMode == CallMode.AudioAndVideo) startCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
var callees = new ICommunicationIdentifier[1]
{
new CommunicationUserIdentifier(destinationUserId)
};
_call = await _callAgent.StartCallAsync(callees, startCallOptions);
}
/// <summary>
/// グループ通話を開始する
/// </summary>
public async Task GroupCallButton_ClickAsync(string groupId)
{
if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();
var groupCallLocator = new GroupCallLocator(Guid.Parse(groupId));
var joinCallOptions = new JoinCallOptions();
if (_callMode == CallMode.AudioAndVideo) joinCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
_call = await _callAgent.JoinAsync(groupCallLocator, joinCallOptions);
}
/// <summary>
/// 通話を終了する
/// </summary>
public async Task HangupButton_ClickAsync()
{
var hangUpOptions = new HangUpOptions();
await _call.HangUpAsync(hangUpOptions);
}
/// <summary>
/// 電話の着信を受け入れる
/// </summary>
private async void Agent_OnIncomingCall(object sender, IncomingCall incomingcall)
{
if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();
var acceptCallOptions = new AcceptCallOptions();
if (_callMode == CallMode.AudioAndVideo) acceptCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
_call = await incomingcall.AcceptAsync(acceptCallOptions);
}
/// <summary>
/// リモート参加者とビデオストリーム
/// </summary>
private async void Agent_OnCallsUpdated(object sender, CallsUpdatedEventArgs args)
{
foreach (var call in args.AddedCalls)
{
foreach (var remoteParticipant in call.RemoteParticipants)
{
var remoteParticipantMRI = remoteParticipant.Identifier.ToString();
_remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant);
await AddVideoStreamsAsync(remoteParticipant.VideoStreams);
remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams);
}
call.OnRemoteParticipantsUpdated += Call_OnRemoteParticipantsUpdated;
call.OnStateChanged += Call_OnStateChanged;
}
}
private async void Call_OnRemoteParticipantsUpdated(object sender, ParticipantsUpdatedEventArgs args)
{
foreach (var remoteParticipant in args.AddedParticipants)
{
var remoteParticipantMRI = remoteParticipant.Identifier.ToString();
_remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant);
await AddVideoStreamsAsync(remoteParticipant.VideoStreams);
remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams);
}
}
/// <summary>
/// 通話状態の更新
/// </summary>
private async void Call_OnStateChanged(object sender, PropertyChangedEventArgs args)
{
switch (((Call)sender).State)
{
// 通話終了
case CallState.Disconnected:
break;
default:
break;
}
}
/// <summary>
/// リモートビデオをレンダリングする
/// </summary>
private async Task AddVideoStreamsAsync(IReadOnlyList<RemoteVideoStream> streams)
{
foreach (var remoteVideoStream in streams)
{
var remoteUri = await remoteVideoStream.Start();
}
}
/// <summary>
/// 利用するカメラを取得する
/// </summary>
private async Task GetCameraDeviceAsync()
{
if (_deviceManager.Cameras.Count > 0)
{
var videoDeviceInfo = _deviceManager.Cameras[0];
_localVideoStream[0] = new LocalVideoStream(videoDeviceInfo);
var localUri = await _localVideoStream[0].MediaUriAsync();
}
}
#endif
}
/// <summary>
/// 通話モード
/// </summary>
public enum CallMode
{
// ビデオ通話
AudioAndVideo = 0,
// 音声通話
Audio,
}
}
#if WINDOWS_UWP using Azure.WinRT.Communication; using Azure.Communication.Calling; #endif using System; using System.Collections.Generic; using System.Threading.Tasks; namespace Nextscape.AzureCommunicationServices { /// <summary> /// AzureCommunicationServicesのCalling SDKの機能を提供します。 /// </summary> public class ACSCalling { #if WINDOWS_UWP private CallMode _callMode = CallMode.AudioAndVideo; private CallClient _callClient; private CallAgent _callAgent; private Call _call; private DeviceManager _deviceManager; private LocalVideoStream[] _localVideoStream; private Dictionary<string, RemoteParticipant> _remoteParticipantDictionary; /// <summary> /// 初期化処理を実行する /// </summary> public async Task InitializeAsync(CallMode callMode, string accessToken, string userName) { _callMode = callMode; _callClient = new CallClient(); if (_callMode == CallMode.AudioAndVideo) { _deviceManager = await _callClient.GetDeviceManager(); _localVideoStream = new LocalVideoStream[1]; } var token_credential = new CommunicationTokenCredential(accessToken); var callAgentOptions = new CallAgentOptions() { DisplayName = userName }; _callAgent = await _callClient.CreateCallAgent(token_credential, callAgentOptions); if (_callMode == CallMode.AudioAndVideo) _callAgent.OnCallsUpdated += Agent_OnCallsUpdated; _callAgent.OnIncomingCall += Agent_OnIncomingCall; } /// <summary> /// 1対1のビデオ通話を開始する /// </summary> public async Task CallButton_ClickAsync(string destinationUserId) { if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync(); var startCallOptions = new StartCallOptions(); if (_callMode == CallMode.AudioAndVideo) startCallOptions.VideoOptions = new VideoOptions(_localVideoStream); var callees = new ICommunicationIdentifier[1] { new CommunicationUserIdentifier(destinationUserId) }; _call = await _callAgent.StartCallAsync(callees, startCallOptions); } /// <summary> /// グループ通話を開始する /// </summary> public async Task GroupCallButton_ClickAsync(string groupId) { if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync(); var groupCallLocator = new GroupCallLocator(Guid.Parse(groupId)); var joinCallOptions = new JoinCallOptions(); if (_callMode == CallMode.AudioAndVideo) joinCallOptions.VideoOptions = new VideoOptions(_localVideoStream); _call = await _callAgent.JoinAsync(groupCallLocator, joinCallOptions); } /// <summary> /// 通話を終了する /// </summary> public async Task HangupButton_ClickAsync() { var hangUpOptions = new HangUpOptions(); await _call.HangUpAsync(hangUpOptions); } /// <summary> /// 電話の着信を受け入れる /// </summary> private async void Agent_OnIncomingCall(object sender, IncomingCall incomingcall) { if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync(); var acceptCallOptions = new AcceptCallOptions(); if (_callMode == CallMode.AudioAndVideo) acceptCallOptions.VideoOptions = new VideoOptions(_localVideoStream); _call = await incomingcall.AcceptAsync(acceptCallOptions); } /// <summary> /// リモート参加者とビデオストリーム /// </summary> private async void Agent_OnCallsUpdated(object sender, CallsUpdatedEventArgs args) { foreach (var call in args.AddedCalls) { foreach (var remoteParticipant in call.RemoteParticipants) { var remoteParticipantMRI = remoteParticipant.Identifier.ToString(); _remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant); await AddVideoStreamsAsync(remoteParticipant.VideoStreams); remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams); } call.OnRemoteParticipantsUpdated += Call_OnRemoteParticipantsUpdated; call.OnStateChanged += Call_OnStateChanged; } } private async void Call_OnRemoteParticipantsUpdated(object sender, ParticipantsUpdatedEventArgs args) { foreach (var remoteParticipant in args.AddedParticipants) { var remoteParticipantMRI = remoteParticipant.Identifier.ToString(); _remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant); await AddVideoStreamsAsync(remoteParticipant.VideoStreams); remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams); } } /// <summary> /// 通話状態の更新 /// </summary> private async void Call_OnStateChanged(object sender, PropertyChangedEventArgs args) { switch (((Call)sender).State) { // 通話終了 case CallState.Disconnected: break; default: break; } } /// <summary> /// リモートビデオをレンダリングする /// </summary> private async Task AddVideoStreamsAsync(IReadOnlyList<RemoteVideoStream> streams) { foreach (var remoteVideoStream in streams) { var remoteUri = await remoteVideoStream.Start(); } } /// <summary> /// 利用するカメラを取得する /// </summary> private async Task GetCameraDeviceAsync() { if (_deviceManager.Cameras.Count > 0) { var videoDeviceInfo = _deviceManager.Cameras[0]; _localVideoStream[0] = new LocalVideoStream(videoDeviceInfo); var localUri = await _localVideoStream[0].MediaUriAsync(); } } #endif } /// <summary> /// 通話モード /// </summary> public enum CallMode { // ビデオ通話 AudioAndVideo = 0, // 音声通話 Audio, } }
#if WINDOWS_UWP
using Azure.WinRT.Communication;
using Azure.Communication.Calling;
#endif
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Nextscape.AzureCommunicationServices
{
    /// <summary>
    /// AzureCommunicationServicesのCalling SDKの機能を提供します。
    /// </summary>
    public class ACSCalling
    {
#if WINDOWS_UWP
        private CallMode _callMode = CallMode.AudioAndVideo;
        private CallClient _callClient;
        private CallAgent _callAgent;
        private Call _call;
        private DeviceManager _deviceManager;
        private LocalVideoStream[] _localVideoStream;
        private Dictionary<string, RemoteParticipant> _remoteParticipantDictionary;

        /// <summary>
        /// 初期化処理を実行する
        /// </summary>
        public async Task InitializeAsync(CallMode callMode, string accessToken, string userName)
        {
            _callMode = callMode;
            _callClient = new CallClient();
            if (_callMode == CallMode.AudioAndVideo)
            {
                _deviceManager = await _callClient.GetDeviceManager();
                _localVideoStream = new LocalVideoStream[1];
            }
            var token_credential = new CommunicationTokenCredential(accessToken);
            var callAgentOptions = new CallAgentOptions()
            {
                DisplayName = userName
            };
            _callAgent = await _callClient.CreateCallAgent(token_credential, callAgentOptions);
            if (_callMode == CallMode.AudioAndVideo) _callAgent.OnCallsUpdated += Agent_OnCallsUpdated;
            _callAgent.OnIncomingCall += Agent_OnIncomingCall;
        }

        /// <summary>
        /// 1対1のビデオ通話を開始する
        /// </summary>
        public async Task CallButton_ClickAsync(string destinationUserId)
        {
            if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();

            var startCallOptions = new StartCallOptions();
            if (_callMode == CallMode.AudioAndVideo) startCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
            var callees = new ICommunicationIdentifier[1]
            {
                new CommunicationUserIdentifier(destinationUserId)
            };
            _call = await _callAgent.StartCallAsync(callees, startCallOptions);
        }

        /// <summary>
        /// グループ通話を開始する
        /// </summary>
        public async Task GroupCallButton_ClickAsync(string groupId)
        {
            if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();

            var groupCallLocator = new GroupCallLocator(Guid.Parse(groupId));
            var joinCallOptions = new JoinCallOptions();
            if (_callMode == CallMode.AudioAndVideo) joinCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
            _call = await _callAgent.JoinAsync(groupCallLocator, joinCallOptions);
        }

        /// <summary>
        /// 通話を終了する
        /// </summary>
        public async Task HangupButton_ClickAsync()
        {
            var hangUpOptions = new HangUpOptions();
            await _call.HangUpAsync(hangUpOptions);
        }

        /// <summary>
        /// 電話の着信を受け入れる
        /// </summary>
        private async void Agent_OnIncomingCall(object sender, IncomingCall incomingcall)
        {
            if (_callMode == CallMode.AudioAndVideo) await GetCameraDeviceAsync();

            var acceptCallOptions = new AcceptCallOptions();
            if (_callMode == CallMode.AudioAndVideo) acceptCallOptions.VideoOptions = new VideoOptions(_localVideoStream);
            _call = await incomingcall.AcceptAsync(acceptCallOptions);
        }

        /// <summary>
        /// リモート参加者とビデオストリーム
        /// </summary>
        private async void Agent_OnCallsUpdated(object sender, CallsUpdatedEventArgs args)
        {
            foreach (var call in args.AddedCalls)
            {
                foreach (var remoteParticipant in call.RemoteParticipants)
                {
                    var remoteParticipantMRI = remoteParticipant.Identifier.ToString();
                    _remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant);
                    await AddVideoStreamsAsync(remoteParticipant.VideoStreams);
                    remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams);
                }
                call.OnRemoteParticipantsUpdated += Call_OnRemoteParticipantsUpdated;
                call.OnStateChanged += Call_OnStateChanged;
            }
        }

        private async void Call_OnRemoteParticipantsUpdated(object sender, ParticipantsUpdatedEventArgs args)
        {
            foreach (var remoteParticipant in args.AddedParticipants)
            {
                var remoteParticipantMRI = remoteParticipant.Identifier.ToString();
                _remoteParticipantDictionary.Add(remoteParticipantMRI, remoteParticipant);
                await AddVideoStreamsAsync(remoteParticipant.VideoStreams);
                remoteParticipant.OnVideoStreamsUpdated += async (s, a) => await AddVideoStreamsAsync(a.AddedRemoteVideoStreams);
            }
        }

        /// <summary>
        /// 通話状態の更新
        /// </summary>
        private async void Call_OnStateChanged(object sender, PropertyChangedEventArgs args)
        {
            switch (((Call)sender).State)
            {
                // 通話終了
                case CallState.Disconnected:
                    break;
                default:
                    break;
            }
        }

        /// <summary>
        /// リモートビデオをレンダリングする
        /// </summary>
        private async Task AddVideoStreamsAsync(IReadOnlyList<RemoteVideoStream> streams)
        {
            foreach (var remoteVideoStream in streams)
            {
                var remoteUri = await remoteVideoStream.Start();
            }
        }

        /// <summary>
        /// 利用するカメラを取得する
        /// </summary>
        private async Task GetCameraDeviceAsync()
        {
            if (_deviceManager.Cameras.Count > 0)
            {
                var videoDeviceInfo = _deviceManager.Cameras[0];
                _localVideoStream[0] = new LocalVideoStream(videoDeviceInfo);
                var localUri = await _localVideoStream[0].MediaUriAsync();
            }
        }
#endif
    }

    /// <summary>
    /// 通話モード
    /// </summary>
    public enum CallMode
    {
        // ビデオ通話
        AudioAndVideo = 0,
        // 音声通話
        Audio,
    }
}

グループビデオ通話が動作している様子

HoloLens 2でACS.UnityアプリとACS.2DUWPアプリを、PCでACS.2DUWPアプリを動作させてグループビデオ通話をしている様子を以下に示します。
動画を見るとわかるように、HoloLens 2とPC間でグループビデオ通話を行い、HoloLens 2でもPC映像のレンダリングができています。
ただし、HoloLens 2で2つのACSを起動し同じグループで通話をしていることにより、自分の声がスピーカーから聞こえる状態となっています。現状、ACSにスピーカーをOFFにする機能はないため、これを解決する手段はありませんが、「3Dオブジェクト込みの映像転送」をしたい場合はACS.Unityアプリだけを使用し、「リモートビデオのレンダリング」をしたい場合はACS.2DUWPアプリだけを使用するといった方法で対応することが可能です。

まとめ

ACSを使用すると、HoloLens 2とPC間でビデオ通話を行うことができます。
ただし、Unityではビデオ映像のレンダリングに非対応となっているため、一工夫が必要となります。
また、本格的にサービスにACSのビデオ通話の機能を組み込んでいく際には、ユーザーID・アクセストークン・グループID等の管理をしっかりと行う必要があります。
まだ機能的に不完全ではあるものの、比較的簡単にビデオ通話を導入できるので、ユースケース次第ではサービスに組み込む選択肢も十分にあると思います。

よかったらシェアしてください

この記事を書いた人