Azure Communication Services のクライアント ライブラリを理解しよう (現状自分が把握している部分の整理)

15 min read

GA した Azure Communication Services を使えば、チャットや音声通話やビデオ通話がアプリに簡単に組み込める!ということで気になったので触ってみました。

触ってみた感じ、JavaScript の SDK のクラス構造とかを整理したほうが理解しやすいなと思ったので現段階で自分が把握している部分だけですが纏めてみました。

前提として通話系のライブラリですが現時点で 1.0.1-beta.1 でした。なので beta が取れるタイミングで少し変わるところがあるかもしれません。

ユーザーとトークン

Azure Communication Services はユーザー管理機能を持ってます。
といっても凄く簡易的なものなので、ちゃんとアプリにユーザー管理機能を付けたい場合は Azure Communication Services のユーザー ID と自前のユーザー管理の ID を紐づけるといったような対応が必要です。

ユーザーを作ったり、ユーザーがチャットや音声通話を開始するためにはアクセストークンが必要になります。ユーザーを作ったりトークンの払い出しはサーバーサイドでやることになるケースが殆どだと思います。これらの操作を行うには、Azure Communication Services のアクセスキーが必要になったりするのでクライアントに持たせるには怖い情報ですね。

これには以下のパッケージを使います。

  • @azure/communication-common
  • @azure/communication-identity

コード的には以下のようになります。

const client = new CommunicationIdentityClient("Azure Communication Services への接続文字列");

// ユーザーを作る
const user = await client.createUser();
// ユーザーは ID を持っているだけのシンプルなデータです。自前管理しているユーザーなどがある場合には
// そのユーザーと ID の紐づけを自前で管理する必要があります。
console.log(`New user id: ${user.communicationUserId}`);

トークンの払い出しは以下のように CommunicationIdentityClientgetToken メソッドで行います。getToken の第二引数がスコープで、今回はビデオ通話をしたいと思っているので voip を指定しています。

スコープの一覧は以下のページに書いてありますが chatvoip です。

https://docs.microsoft.com/ja-jp/azure/communication-services/concepts/identity-model
// トークンを生成
const { token, expiresOn } = await client.getToken(user, ["voip"]);
console.log(`有効期限: ${expiresOn}`);
console.log(token);

getToken に渡す user は上で作成した createUser の戻り値か、ID がわかっているなら以下のように指定することも出来ます。

const { token, expiresOn } = await client.getToken({ communicationUserId: 'ユーザー ID' }, ["voip"]);

ビデオ通話の開始

トークンをクライアント側のアプリケーションで取得できたら通話機能を使う準備が出来ました。
多分、トークン発行してくれる Web API かなんかを作って、そこから取得することになります。クライアント側のアプリケーションでは以下のパッケージを使用します。

  • @azure/communication-calling
  • @azure/communication-common

@azure/communication-calling に含まれている CallClient というクラスが通話や通話で使用するデバイスを管理するクラスになります。
CallClient 自体には通話をするための機能は持っていなくて、CallClient からトークンを使って生成する CallAgent クラスが実際の通話関連の機能を担当しています。

デバイス管理も CallClient から DeviceManager というクラスが取得できて、そこで管理することが出来ます。

デバイスの管理

デバイスマネージャーは CallClientgetDeviceManager メソッドで取得できます。戻り値は Promise<DeviceManager> なので await をして DeviceManager のインスタンスを取得します。

const callClient = new CallClient(); // CallClient は、ただ new するだけ
const deviceManager = await callClient.getDeviceManager(); // DevicdManager ください

CallClientDeviceManager のインスタンスはアプリ内で 1 つが基本になると思います。

DeviceManager からデバイスへのアクセス権限を要求するには askDevicePermission メソッドを使用します。

const { audio, video } = await this.deviceManager.askDevicePermission({ audio: true, video: true });

引数で、オーディオとビデオの権限を要求するかどうかを指定して戻り値は、要求されたかどうかが boolean 型で帰ってきます。ちゃんとユーザーに拒否されたときのフォローも出来るようなロジックにしておく必要があります。
一度ユーザーが拒否すると、このメソッドを呼び出してもユーザーにプロンプトは表示されないのでブラウザーの設定画面に行って許可してもらう必要がありそうな気がします。

デバイスの列挙と使用デバイスの設定

デバイスには 3 種類あります。スピーカー、マイク、カメラです。
それぞれ DeviceManagergetSpeakers, getMicrophones, getCameras の 3 種類です。戻り値は Promise なので await することで、それぞれのデバイスの情報が取得できます。

getSpeakersgetMicrophones の戻り値は AudioDeviceInfo の配列で以下のような定義になっています。

AudioDeviceInfo
export declare interface AudioDeviceInfo {
    /**
     * Get the name of this video device.
     */
    readonly name: string;
    /**
     * Get Id of this video device.
     */
    readonly id: string;
    /**
     * Is this the systems default audio device
     */
    readonly isSystemDefault: boolean;
    /**
     * Get this audio device type
     */
    readonly deviceType: AudioDeviceType;
}

 * Type of an audio device
 * @public
 */
export declare type AudioDeviceType = 'Microphone' | 'Speaker' | 'CompositeAudioDevice';

通話で使用するスピーカーとマイクは DeviceManagerselectSpeakerselectMicrophone メソッドで設定します。
引数は AudioDeviceInfo になります。現在選択されているデバイスは selectedSpeakerselectedMicrophone で確認できます。

デバイスは、割とつけたり外したりとかが激しい (Bluetooth デバイスだと特にね…) ので、デバイスに変更があった場合のイベントも DeviceManager に定義されています。

/**
 * Subscribe function for videoDevicesUpdated event
 * @param event - event name
 * @param listener - callback fn that will be called when value of this property will change
 */
on(event: 'videoDevicesUpdated', listener: CollectionUpdatedEvent<VideoDeviceInfo>): void;
/**
 * Subscribe function for audioDevicesUpdated event
 * @param event - event name
 * @param listener - callback fn that will be called when value of this property will change
 */
on(event: 'audioDevicesUpdated', listener: CollectionUpdatedEvent<AudioDeviceInfo>): void;
/**
 * Subscribe function for selectedMicrophoneChanged event
 * @param event - event name
 * @param listener - callback fn that will be called when value of this property will change
 */
on(event: 'selectedMicrophoneChanged', listener: PropertyChangedEvent): void;
/**
 * Subscribe function for selectedSpeakerChanged event
 * @param event - event name
 * @param listener - callback fn that will be called when value of this property will change
 */
on(event: 'selectedSpeakerChanged', listener: PropertyChangedEvent): void;

CollectionUpdatedEventaddedremoved で追加、削除された要素が判別出来るようになっているので、それを使って最新のデバイスの状況を確認できます。

/**
 * Event that a collection of objects has been updated
 */
export declare type CollectionUpdatedEvent<T> = (args: {
    added: T[];
    removed: T[];
}) => void;

実際にハンドリングする場合は以下のような感じのコードになります。

deviceManager.on('audioDevicesUpdated', args => {
    for (const device of args.added) {
        // 追加されたデバイス
    }
    for (const device of args.removed) {
        // 追加されたデバイス
    }
});

カメラを取得する getCamerasVideoDeviceInfo の配列が返ってきます。VideoDeviceInfo は以下のような定義になっています。

export declare interface VideoDeviceInfo {
    /**
     * Get the name of this video device.
     */
    readonly name: string;
    /**
     * Get Id of this video device.
     */
    readonly id: string;
    /**
     * Get this video device type
     */
    readonly deviceType: VideoDeviceType;
}

/**
 * Type of a video device
 * @public
 */
export declare type VideoDeviceType = 'Unknown' | 'UsbCamera' | 'CaptureAdapter' | 'Virtual';

スピーカーとマイクは DeviceManager にセットしておしまいなのですが、カメラだけ扱いが少し異なります。カメラは、通話に対して、どのカメラを使うかという形で設定します。なのでカメラの設定については通話を始める部分の解説で扱おうと思います。

通話をする

Azure Communication Services では 1 対 1 の通話と、グループ通話の機能を利用することが可能です。
通話をするには CallAgent を使用する必要があります。

CallAgentCallClient からトークンを利用して作成します。基本的にはサーバーサイドで生成してもらったトークンの有効期限が 24 時間くらいあるので 24 時間立ち上げっぱなしのようなアプリでない限りは以下のように何も考えずにトークンを使う形でも大丈夫かなと思います。

// token にアクセストークン
const tokenCredential = new AzureCommunicationTokenCredential(token);
const callAgent = await callClient.createCallAgent(tokenCredential);

トークンのリフレッシュにも対応していて以下のドキュメントに記載があります。

https://azuresdkdocs.blob.core.windows.net/$web/javascript/azure-communication-common/1.0.0/index.html

公式サンプルでは、以下の部分でトークンのリフレッシュ機能を実現しています。

AzureCommunicationTokenCredential の生成処理

https://github.com/Azure-Samples/communication-services-web-calling-hero/blob/main/Calling/ClientApp/src/core/sideEffects.ts#L228

リフレッシュトークン取得処理を呼び出している部分

https://github.com/Azure-Samples/communication-services-web-calling-hero/blob/e8bd4bff9cfefe5f3764b542c3a0db70c19ee1b2/Calling/ClientApp/src/Utils/Utils.ts#L20

リフレッシュトークンを生成しているサーバーサイドの処理

https://github.com/Azure-Samples/communication-services-web-calling-hero/blob/e8bd4bff9cfefe5f3764b542c3a0db70c19ee1b2/Calling/Controllers/UserTokenController.cs#L65

通話への参加方法

通話への参加方法はいくつかありますが、大まかに以下の 3 つのパターンを押さえておけばいいと思います。

  • 特定のユーザーに対して電話をかける
  • 特定のグループ通話に参加する
  • 別のユーザーからの電話を受ける

1 つずつ見てみましょう。

特定のユーザーに対して電話をかける

ユーザーに電話をかけるには、電話をかける先のユーザーの ID が必要になります。ID は CommunicationIdentityClientcreateUser したときに返されるユーザーの communicationUserId の値です。あとはビデオ通話をする場合は DeviceManagergetCameras メソッドで返されるものの中から使いたいものを渡してやります。

戻り値は Call という型のインスタンスです。

const call = callAgent.startCall(
    [{ communicationUserId: remoteId }], // remoteId が電話をかける相手先ユーザーの ID
    { 
        videoOptions: { 
            localVideoStreams: [ 
                new LocalVideoStream(videoDeviceInfo), // 使いたいカメラの VideoDeviceInfo を渡す
            ] 
        } 
    });

実際のカメラ映像のレンダリングは、通話への参加方法にかかわらず共通なので後でまとめて紹介したいと思います。通話を終了するには Call クラスの hangUp を呼べば抜けられます。

特定のグループ通話に参加する

グループ通話に参加するには CallAgentjoin メソッドで行います。戻り値は startCall と同じで Call 型のインスタンスです。
グループ自体の管理機能は Azure Communication Services には無いので、グループの管理は自前アプリで行う必要があります。指定した groupId のグループがない場合は一人だけのグループ通話になって、誰かが入ってくるのを待つ状態になります。

const call = this.callAgent.join({ groupId: "xxxxxxxxx-xxxx-xxxx-xxxxxxxxxxx" });

トークンの更新のところで紹介した公式サンプルでは、この groupId 込みの URL を何らかの方法で通話したい人とシェアしてグループ通話を実現していました。

別のユーザーからの電話を受ける

人から通話がかかってくると CallAgentincomingCall イベントが発生するので、そこのイベント引数の incomingCall.accept メソッドを呼ぶことで受付が可能です。

callAgent.on('incomingCall', async e => {
    const call = await e.incomingCall.accept({ 
        videoOptions: { localVideoStreams: [localVideoStream] } // LocalVideoStream を渡すことでカメラがオンの状態で通話を受けることが出来る
    });
});

自分のビデオを画面に表示したい

VideoDeviceInfo から LocalVideoStream を作ります。LocalVideoStream を使って VideoStreamRenderer を作ってレンダリングを行います。
VideoStreamRenderercreateView メソッドで VideoStreamRendererView を作って target プロパティで DOM に追加するための HTMLElement が取得できるので任意の要素に appencChild あたりで追加してやれば、そこにカメラの映像が表示されます。

VideoStreamRenderer に対して dispose を呼び出すとビデオが止まります。

// VideoDeviceInfo から LocalVideoStream を作る
const localVideoStream = new LocalVideoStream(videoDeviceInfo); 
// LocalVideoStream から VideoStreamRenderer を作る
const videoRenderer = new VideoStreamRenderer(localVideoStream);
// createView をした結果の target がビデオがレンダリングされる HTMLElement
const videoRendererView = await this.videoRenderer.createView();
const element = videoRendererView.target;

// こんな感じで適当な要素に append すると画面に表示される
document.getElementById("someDivTagId").appendChild(element);

// ----------------------------------------
// ビデオのレンダリングを止める場合は以下のように dispose を呼べばレンダラーを破棄出来る
videoRenderView.dispose(); // view を破棄
videoRenderer.dispose(); // renderer を破棄

// ----------------------------------------
// ビデオのデバイスを変えたい場合は LocalVideoStream の switchSource で切替
await localVideoStream.switchSource(anotherVideoDeviceInfo);

相手のビデオを画面に表示したい

ちょっと複雑です。相手のビデオのオンオフへの対応やユーザーが追加されたときとか考えないといけません…。
グループ通話への対応は、まだ確認中なので後で記事を更新するかもしれません。

ここでは 1 対 1 通話の場合を考えます。1 対 1 通話の場合は Call オブジェクトの remoteParticipants を見ると通話相手が入っているのでそこに対してリモートのビデオストリームを取得してレンダリングします。ビデオのオン・オフとかがあるので isAvailabilityChanged イベントのハンドリングをして有効・無効にも対応します…。結構難しい。

// remoteRenderer という通話相手用の VideoStreamRender という変数は別で定義されている想定のコード

// リモートのビデオストリームを秒がする処理
const createVideoStreamRenderer = async (stream: RemoteVideoStream) => {
    if (stream.isAvailable) {
        // 有効ならレンダラーを作って View を作って表示する
        remoteRenderer?.dispose();
        remoteRenderer = new VideoStreamRenderer(stream);
        const view = await remoteRenderer.createView();
        const placeHolderForView = document.getElementById("someId");
        placeHolderForView.innerHTML = "";
        placeHolderForView.appendChild(view);
    } else {
        // 有効じゃないなら消す
        remoteRenderer?.dispose();
        const placeHolderForView = document.getElementById("someId");
        placeHolderForView.innerHTML = "";
    }
};

// 通話相手を remotePariticipants で取得できるので、その数だけループ
for(const remote of call.remoteParticipants) {
    // 通話相手のビデオストリームに対して有効化・無効化のイベントをハンドリングしつつ、とりあえず初回の表示処理をやる
    for (const remoteVideoStream of remote.videoStreams) {
        remoteVideoStream.on('isAvailableChanged', async() => {
            createVideoStreamRenderer(remoteVideoStream);
        });

        createVideoStreamRenderer(remoteVideoStream);
    }
}

グループチャットの場合は Call に対して remoteParticipantsUpdated イベントがあるので、ここでリモートの人のビデオストリームに対応する処理を書くことになると思います。地味に複雑ですね…。

this.call.on('remoteParticipantsUpdated', args => {
    for(const addedUser of args.added) {
        // ユーザー追加に対応
    }

    for (const removedUser of args.removed) {
        // ユーザー削除の場合に対応
    }
});

まとめ

何日かかけてドキュメントとサンプルを見てみた結果わかったことを書いてみました。

まだわかってないことが沢山あります。スクリーンシェアとかグループ通話にユーザーを追加とかマイクのミュートとかのメソッドが Call クラスにありますが、実際に呼んで試すところまでは出来ていません。こんな感じのメソッドがあります。(使い方はシグネチャから想像はできますね)

/**
 * Mute local microphone.
 */
mute(): Promise<void>;
/**
 * Unmute local microphone.
 */
unmute(): Promise<void>;
/**
 * Send DTMF tone.
 */
sendDtmf(dtmfTone: DtmfTone): Promise<void>;
/**
 * Start sending video stream in the call.
 * @param localVideoStream - Represents a local video stream and takes a camera in constructor.
 */
startVideo(localVideoStream: LocalVideoStream): Promise<void>;
/**
 * Stop local video, pass localVideoStream you got from call.startVideo() API call.
 * @param localVideoStream - The local video stream to stop streaming.
 */
stopVideo(localVideoStream: LocalVideoStream): Promise<void>;
/**
 * Add a participant to this Call.
 * @param identifier - The identifier of the participant to add.
 * @param options - Additional options for managing the PSTN call. For example, setting the Caller Id phone number in a PSTN call.
 * @returns The RemoteParticipant object associated with the successfully added participant.
 */
addParticipant(identifier: CommunicationUserIdentifier | MicrosoftTeamsUserIdentifier): RemoteParticipant;
addParticipant(identifier: PhoneNumberIdentifier, options?: AddPhoneNumberOptions): RemoteParticipant;
/**
 * Remove a participant from this Call.
 * @param identifier - The identifier of the participant to remove.
 * @param options - options
 */
removeParticipant(identifier: CommunicationUserIdentifier | PhoneNumberIdentifier | MicrosoftTeamsUserIdentifier | UnknownIdentifier): Promise<void>;
/**
 * Put this Call on hold.
 */
hold(): Promise<void>;
/**
 * Resume this Call.
 */
resume(): Promise<void>;
/**
 * Start local screen sharing, browser handles screen/window enumeration and selection.
 */
startScreenSharing(): Promise<void>;
/**
 * Stop local screen sharing.
 */
stopScreenSharing(): Promise<void>;

Discussion

ログインするとコメントできます